初始化移动端提交
This commit is contained in:
233
sheep/components/company-dept-dialog/company-dept-dialog.vue
Normal file
233
sheep/components/company-dept-dialog/company-dept-dialog.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<view v-if="dialogStore.show" class="company-dept-dialog">
|
||||
<view class="company-dept-dialog__mask" @tap="handleCancel" />
|
||||
<view class="company-dept-dialog__panel">
|
||||
<view class="company-dept-dialog__header">
|
||||
<text class="company-dept-dialog__title">{{ dialogStore.title }}</text>
|
||||
</view>
|
||||
<view class="company-dept-dialog__body">
|
||||
<view class="company-dept-dialog__field">
|
||||
<text class="company-dept-dialog__label">公司</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="companyOptions"
|
||||
range-key="companyName"
|
||||
:value="companyIndex"
|
||||
@change="onCompanyChange"
|
||||
>
|
||||
<view class="company-dept-dialog__picker">
|
||||
<text class="company-dept-dialog__picker-text">
|
||||
{{ currentCompanyName }}
|
||||
</text>
|
||||
<text class="company-dept-dialog__arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="company-dept-dialog__field">
|
||||
<text class="company-dept-dialog__label">部门</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="deptOptions"
|
||||
range-key="deptName"
|
||||
:value="deptIndex"
|
||||
@change="onDeptChange"
|
||||
>
|
||||
<view class="company-dept-dialog__picker">
|
||||
<text class="company-dept-dialog__picker-text">
|
||||
{{ currentDeptName }}
|
||||
</text>
|
||||
<text class="company-dept-dialog__arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="company-dept-dialog__footer">
|
||||
<button class="company-dept-dialog__btn company-dept-dialog__btn--cancel" @tap="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="company-dept-dialog__btn company-dept-dialog__btn--confirm"
|
||||
:class="{ 'company-dept-dialog__btn--disabled': !isConfirmEnabled }"
|
||||
:disabled="!isConfirmEnabled"
|
||||
@tap="handleConfirm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
|
||||
const dialogStore = sheep.$store('company-dept');
|
||||
|
||||
const companyOptions = computed(() => dialogStore.companyList || []);
|
||||
const deptOptions = computed(() => dialogStore.getDeptsByCompanyId(dialogStore.selectedCompanyId));
|
||||
|
||||
const companyIndex = computed(() => {
|
||||
const index = companyOptions.value.findIndex((item) => item.companyId === dialogStore.selectedCompanyId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const deptIndex = computed(() => {
|
||||
const index = deptOptions.value.findIndex((item) => item.deptId === dialogStore.selectedDeptId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const currentCompanyName = computed(() => {
|
||||
if (!companyOptions.value.length) return '暂无公司可选';
|
||||
const company = companyOptions.value.find((item) => item.companyId === dialogStore.selectedCompanyId);
|
||||
return company?.companyName || '请选择公司';
|
||||
});
|
||||
|
||||
const currentDeptName = computed(() => {
|
||||
if (!deptOptions.value.length) return '暂无部门可选';
|
||||
const dept = deptOptions.value.find((item) => item.deptId === dialogStore.selectedDeptId);
|
||||
return dept?.deptName || '请选择部门';
|
||||
});
|
||||
|
||||
const isConfirmEnabled = computed(() => !!dialogStore.selectedCompanyId && !!dialogStore.selectedDeptId);
|
||||
|
||||
const onCompanyChange = (event) => {
|
||||
const index = event?.detail?.value;
|
||||
if (index === undefined) return;
|
||||
const company = companyOptions.value[index];
|
||||
if (company) {
|
||||
dialogStore.setSelectedCompany(company.companyId);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeptChange = (event) => {
|
||||
const index = event?.detail?.value;
|
||||
if (index === undefined) return;
|
||||
const dept = deptOptions.value[index];
|
||||
if (dept) {
|
||||
dialogStore.setSelectedDept(dept.deptId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogStore.cancel();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isConfirmEnabled.value) return;
|
||||
dialogStore.confirm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.company-dept-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
&__panel {
|
||||
position: relative;
|
||||
width: 620rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx 40rpx 32rpx;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 28rpx;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&__picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f7f8fa;
|
||||
border: 1rpx solid #e4e7ed;
|
||||
}
|
||||
|
||||
&__picker-text {
|
||||
font-size: 28rpx;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
font-size: 24rpx;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__btn--cancel {
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
border: 1rpx solid transparent;
|
||||
}
|
||||
|
||||
&__btn--confirm {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(90deg, #2f6bff 0%, #3c8bff 100%);
|
||||
border: 1rpx solid transparent;
|
||||
}
|
||||
|
||||
&__btn--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
sheep/components/s-auth-modal/components/change-mobile.vue
Normal file
145
sheep/components/s-auth-modal/components/change-mobile.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<!-- 绑定/更换手机号 changeMobile -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 标题栏 -->
|
||||
<view class="head-box ss-m-b-60">
|
||||
<view class="head-title ss-m-b-20">
|
||||
{{ userInfo.mobile ? '更换手机号' : '绑定手机号' }}
|
||||
</view>
|
||||
<view class="head-subtitle">为了您的账号安全,请使用本人手机号码</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单项 -->
|
||||
<uni-forms
|
||||
ref="changeMobileRef"
|
||||
v-model="state.model"c
|
||||
:rules="state.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<uni-forms-item name="mobile" label="手机号">
|
||||
<uni-easyinput
|
||||
placeholder="请输入手机号"
|
||||
v-model="state.model.mobile"
|
||||
:inputBorder="false"
|
||||
type="number"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button
|
||||
class="ss-reset-button code-btn-start"
|
||||
:disabled="state.isMobileEnd"
|
||||
:class="{ 'code-btn-end': state.isMobileEnd }"
|
||||
@tap="getSmsCode('changeMobile', state.model.mobile)"
|
||||
>
|
||||
{{ getSmsTimer('changeMobile') }}
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="code" label="验证码">
|
||||
<uni-easyinput
|
||||
placeholder="请输入验证码"
|
||||
v-model="state.model.code"
|
||||
:inputBorder="false"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button class="ss-reset-button login-btn-start" @tap="changeMobileSubmit">
|
||||
确认
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<!-- 微信独有:读取手机号 -->
|
||||
<button
|
||||
v-if="'WechatMiniProgram' === sheep.$platform.name"
|
||||
class="ss-reset-button type-btn"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="getPhoneNumber"
|
||||
>
|
||||
使用微信手机号
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, unref } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { code, mobile } from '@/sheep/validate/form';
|
||||
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
|
||||
import UserApi from '@/sheep/api/system/user';
|
||||
|
||||
const changeMobileRef = ref(null);
|
||||
const userInfo = computed(() => sheep.$store('user').userInfo);
|
||||
|
||||
// 数据
|
||||
const state = reactive({
|
||||
isMobileEnd: false, // 手机号输入完毕
|
||||
model: {
|
||||
mobile: '', // 手机号
|
||||
code: '', // 验证码
|
||||
},
|
||||
rules: {
|
||||
code,
|
||||
mobile,
|
||||
},
|
||||
});
|
||||
|
||||
// 绑定手机号
|
||||
async function changeMobileSubmit() {
|
||||
const validate = await unref(changeMobileRef)
|
||||
.validate()
|
||||
.catch((error) => {
|
||||
console.log('error: ', error);
|
||||
});
|
||||
if (!validate) {
|
||||
return;
|
||||
}
|
||||
// 提交更新请求
|
||||
const { code } = await UserApi.updateUserMobile(state.model);
|
||||
if (code !== 0) {
|
||||
return;
|
||||
}
|
||||
sheep.$store('user').getInfo();
|
||||
// 绑定成功后返回上一页或首页
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/menu'
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 使用微信手机号
|
||||
async function getPhoneNumber(e) {
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
return;
|
||||
}
|
||||
const result = await sheep.$platform.useProvider().bindUserPhoneNumber(e.detail);
|
||||
if (result) {
|
||||
sheep.$store('user').getInfo();
|
||||
// 绑定成功后返回上一页或首页
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/menu'
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../index.scss';
|
||||
</style>
|
||||
125
sheep/components/s-auth-modal/components/change-password.vue
Normal file
125
sheep/components/s-auth-modal/components/change-password.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<!-- 修改密码(登录时) -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 标题栏 -->
|
||||
<view class="head-box ss-m-b-60">
|
||||
<view class="head-title ss-m-b-20">修改密码</view>
|
||||
<view class="head-subtitle">如密码丢失或未设置,请点击忘记密码重新设置</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单项 -->
|
||||
<uni-forms
|
||||
ref="changePasswordRef"
|
||||
v-model="state.model"
|
||||
:rules="state.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<uni-forms-item name="code" label="验证码">
|
||||
<uni-easyinput
|
||||
placeholder="请输入验证码"
|
||||
v-model="state.model.code"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
:inputBorder="false"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button
|
||||
class="ss-reset-button code-btn code-btn-start"
|
||||
:disabled="state.isMobileEnd"
|
||||
:class="{ 'code-btn-end': state.isMobileEnd }"
|
||||
@tap="getSmsCode('changePassword')"
|
||||
>
|
||||
{{ getSmsTimer('resetPassword') }}
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="reNewPassword" label="密码">
|
||||
<uni-easyinput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
v-model="state.model.password"
|
||||
:inputBorder="false"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button class="ss-reset-button login-btn-start" @tap="changePasswordSubmit">
|
||||
确认
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<button class="ss-reset-button type-btn" @tap="goBack">
|
||||
取消修改
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, unref } from 'vue';
|
||||
import { code, password } from '@/sheep/validate/form';
|
||||
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
|
||||
import UserApi from '@/sheep/api/system/user';
|
||||
|
||||
const changePasswordRef = ref(null);
|
||||
|
||||
// 数据
|
||||
const state = reactive({
|
||||
model: {
|
||||
mobile: '', // 手机号
|
||||
code: '', // 验证码
|
||||
password: '', // 密码
|
||||
},
|
||||
rules: {
|
||||
code,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
function goBack() {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/menu'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更改密码
|
||||
async function changePasswordSubmit() {
|
||||
// 参数校验
|
||||
const validate = await unref(changePasswordRef)
|
||||
.validate()
|
||||
.catch((error) => {
|
||||
console.log('error: ', error);
|
||||
});
|
||||
if (!validate) {
|
||||
return;
|
||||
}
|
||||
// 发起请求
|
||||
const { code } = await UserApi.updateUserPassword(state.model);
|
||||
if (code !== 0) {
|
||||
return;
|
||||
}
|
||||
// 成功后返回上一页或首页
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/menu'
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../index.scss';
|
||||
</style>
|
||||
160
sheep/components/s-auth-modal/components/mp-authorization.vue
Normal file
160
sheep/components/s-auth-modal/components/mp-authorization.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<!-- 微信授权信息 mpAuthorization -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 标题栏 -->
|
||||
<view class="head-box ss-m-b-60 ss-flex-col">
|
||||
<view class="ss-flex ss-m-b-20">
|
||||
<view class="head-title ss-m-r-40 head-title-animation">授权信息</view>
|
||||
</view>
|
||||
<view class="head-subtitle">完善您的头像、昵称、手机号</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单项 -->
|
||||
<uni-forms
|
||||
ref="accountLoginRef"
|
||||
v-model="state.model"
|
||||
:rules="state.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<!-- 获取头像昵称:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html -->
|
||||
<uni-forms-item name="avatar" label="头像">
|
||||
<button
|
||||
class="ss-reset-button avatar-btn"
|
||||
open-type="chooseAvatar"
|
||||
@chooseavatar="onChooseAvatar"
|
||||
>
|
||||
<image
|
||||
class="avatar-img"
|
||||
:src="sheep.$url.cdn(state.model.avatar)"
|
||||
mode="aspectFill"
|
||||
@tap="sheep.$router.go('/pages/user/info')"
|
||||
/>
|
||||
<text class="cicon-forward" />
|
||||
</button>
|
||||
</uni-forms-item>
|
||||
<uni-forms-item name="nickname" label="昵称">
|
||||
<uni-easyinput
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
v-model="state.model.nickname"
|
||||
:inputBorder="false"
|
||||
/>
|
||||
</uni-forms-item>
|
||||
<view class="foot-box">
|
||||
<button class="ss-reset-button authorization-btn" @tap="onConfirm"> 确认授权 </button>
|
||||
</view>
|
||||
</uni-forms>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import FileApi from '@/sheep/api/infra/file';
|
||||
import UserApi from '@/sheep/api/system/user';
|
||||
|
||||
const props = defineProps({
|
||||
agreeStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const userInfo = computed(() => sheep.$store('user').userInfo);
|
||||
|
||||
const accountLoginRef = ref(null);
|
||||
|
||||
// 数据
|
||||
const state = reactive({
|
||||
model: {
|
||||
nickname: userInfo.value.nickname,
|
||||
avatar: userInfo.value.avatar,
|
||||
},
|
||||
rules: {},
|
||||
disabledStyle: {
|
||||
color: '#999',
|
||||
disableColor: '#fff',
|
||||
},
|
||||
});
|
||||
|
||||
// 选择头像(来自微信)
|
||||
function onChooseAvatar(e) {
|
||||
const tempUrl = e.detail.avatarUrl || '';
|
||||
uploadAvatar(tempUrl);
|
||||
}
|
||||
|
||||
// 选择头像(来自文件系统)
|
||||
async function uploadAvatar(tempUrl) {
|
||||
if (!tempUrl) {
|
||||
return;
|
||||
}
|
||||
let { data } = await FileApi.uploadFile(tempUrl);
|
||||
state.model.avatar = data;
|
||||
}
|
||||
|
||||
// 确认授权
|
||||
async function onConfirm() {
|
||||
const { model } = state;
|
||||
const { nickname, avatar } = model;
|
||||
if (!nickname) {
|
||||
sheep.$helper.toast('请输入昵称');
|
||||
return;
|
||||
}
|
||||
if (!avatar) {
|
||||
sheep.$helper.toast('请选择头像');
|
||||
return;
|
||||
}
|
||||
// 发起更新
|
||||
const { code } = await UserApi.updateUser({
|
||||
avatar: state.model.avatar,
|
||||
nickname: state.model.nickname,
|
||||
});
|
||||
// 更新成功
|
||||
if (code === 0) {
|
||||
sheep.$helper.toast('授权成功');
|
||||
await sheep.$store('user').getInfo();
|
||||
// 授权成功后返回上一页或首页
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/menu'
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../index.scss';
|
||||
|
||||
.foot-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.authorization-btn {
|
||||
width: 686rpx;
|
||||
height: 80rpx;
|
||||
background-color: var(--ui-BG-Main);
|
||||
border-radius: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.avatar-img {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
}
|
||||
.cicon-forward {
|
||||
font-size: 30rpx;
|
||||
color: #595959;
|
||||
}
|
||||
.avatar-btn {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
126
sheep/components/s-auth-modal/components/reset-password.vue
Normal file
126
sheep/components/s-auth-modal/components/reset-password.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<!-- 重置密码(未登录时) -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 标题栏 -->
|
||||
<view class="head-box ss-m-b-60">
|
||||
<view class="head-title ss-m-b-20">重置密码</view>
|
||||
<view class="head-subtitle">为了您的账号安全,设置密码前请先进行安全验证</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单项 -->
|
||||
<uni-forms
|
||||
ref="resetPasswordRef"
|
||||
v-model="state.model"
|
||||
:rules="state.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<uni-forms-item name="mobile" label="手机号">
|
||||
<uni-easyinput
|
||||
placeholder="请输入手机号"
|
||||
v-model="state.model.mobile"
|
||||
type="number"
|
||||
:inputBorder="false"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button
|
||||
class="ss-reset-button code-btn code-btn-start"
|
||||
:disabled="state.isMobileEnd"
|
||||
:class="{ 'code-btn-end': state.isMobileEnd }"
|
||||
@tap="getSmsCode('resetPassword', state.model.mobile)"
|
||||
>
|
||||
{{ getSmsTimer('resetPassword') }}
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="code" label="验证码">
|
||||
<uni-easyinput
|
||||
placeholder="请输入验证码"
|
||||
v-model="state.model.code"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
:inputBorder="false"
|
||||
/>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="password" label="密码">
|
||||
<uni-easyinput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
v-model="state.model.password"
|
||||
:inputBorder="false"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button class="ss-reset-button login-btn-start" @tap="resetPasswordSubmit">
|
||||
确认
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<button v-if="!isLogin" class="ss-reset-button type-btn" @tap="goToLogin">
|
||||
返回登录
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, unref } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { code, mobile, password } from '@/sheep/validate/form';
|
||||
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
|
||||
import UserApi from '@/sheep/api/system/user';
|
||||
|
||||
// 跳转到登录页
|
||||
function goToLogin() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/login/index'
|
||||
});
|
||||
}
|
||||
|
||||
const resetPasswordRef = ref(null);
|
||||
const isLogin = computed(() => sheep.$store('user').isLogin);
|
||||
|
||||
// 数据
|
||||
const state = reactive({
|
||||
isMobileEnd: false, // 手机号输入完毕
|
||||
model: {
|
||||
mobile: '', // 手机号
|
||||
code: '', // 验证码
|
||||
password: '', // 密码
|
||||
},
|
||||
rules: {
|
||||
code,
|
||||
mobile,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
// 重置密码
|
||||
const resetPasswordSubmit = async () => {
|
||||
// 参数校验
|
||||
const validate = await unref(resetPasswordRef)
|
||||
.validate()
|
||||
.catch((error) => {
|
||||
console.log('error: ', error);
|
||||
});
|
||||
if (!validate) {
|
||||
return;
|
||||
}
|
||||
// 发起请求
|
||||
const { code } = await UserApi.resetUserPassword(state.model);
|
||||
if (code !== 0) {
|
||||
return;
|
||||
}
|
||||
// 成功后,用户重新登录
|
||||
goToLogin();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../index.scss';
|
||||
</style>
|
||||
430
sheep/components/s-auth-modal/components/unified-login.vue
Normal file
430
sheep/components/s-auth-modal/components/unified-login.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<!-- 统一登录组件 - 整合账号密码登录和短信登录 -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 标题栏 -->
|
||||
<view class="head-box ss-m-b-60 ss-flex-col">
|
||||
<!-- 登录方式切换标签 -->
|
||||
<view class="ss-flex ss-m-b-20">
|
||||
<view
|
||||
class="head-title ss-m-r-40"
|
||||
:class="{ 'head-title-active': loginType === 'account', 'head-title-animation': loginType === 'account' }"
|
||||
@tap="switchLoginType('account')"
|
||||
>
|
||||
账号登录
|
||||
</view>
|
||||
<!-- <view
|
||||
class="head-title"
|
||||
:class="{ 'head-title-active': loginType === 'sms', 'head-title-animation': loginType === 'sms' }"
|
||||
@tap="switchLoginType('sms')"
|
||||
>
|
||||
短信登录
|
||||
</view> -->
|
||||
</view>
|
||||
<!-- 副标题 -->
|
||||
<view class="head-subtitle">
|
||||
{{ loginType === 'account' ? '如果未设置过密码,请点击忘记密码' : '未注册的手机号,验证后自动注册账号' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<uni-forms
|
||||
v-if="loginType === 'account'"
|
||||
ref="accountLoginRef"
|
||||
v-model="accountState.model"
|
||||
:rules="accountState.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<uni-forms-item name="username" label="账号">
|
||||
<uni-easyinput placeholder="请输入账号" v-model="accountState.model.username" :inputBorder="false">
|
||||
<template v-slot:right>
|
||||
<!-- <button class="ss-reset-button forgot-btn" @tap="showAuthModal('resetPassword')">
|
||||
忘记密码
|
||||
</button> -->
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="password" label="密码">
|
||||
<uni-easyinput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
v-model="accountState.model.password"
|
||||
:inputBorder="false"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button class="ss-reset-button login-btn-start" @tap="handleAccountLogin">登录</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<!-- 短信登录表单 -->
|
||||
<uni-forms
|
||||
v-if="loginType === 'sms'"
|
||||
ref="smsLoginRef"
|
||||
v-model="smsState.model"
|
||||
:rules="smsState.rules"
|
||||
validateTrigger="bind"
|
||||
labelWidth="140"
|
||||
labelAlign="center"
|
||||
>
|
||||
<uni-forms-item name="mobile" label="手机号">
|
||||
<uni-easyinput
|
||||
placeholder="请输入手机号"
|
||||
v-model="smsState.model.mobile"
|
||||
:inputBorder="false"
|
||||
type="number"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button
|
||||
class="ss-reset-button code-btn code-btn-start"
|
||||
:disabled="!canSendSms"
|
||||
:class="{ 'code-btn-end': !canSendSms }"
|
||||
@tap="checkAgreementAndGetSmsCode"
|
||||
>
|
||||
{{ smsTimer }}
|
||||
</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
|
||||
<uni-forms-item name="code" label="验证码">
|
||||
<uni-easyinput
|
||||
placeholder="请输入验证码"
|
||||
v-model="smsState.model.code"
|
||||
:inputBorder="false"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
>
|
||||
<template v-slot:right>
|
||||
<button class="ss-reset-button login-btn-start" @tap="handleSmsLogin">登录</button>
|
||||
</template>
|
||||
</uni-easyinput>
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<!-- 验证码弹窗 -->
|
||||
<s-verify
|
||||
ref="verifyRef"
|
||||
:captcha-type="captchaType"
|
||||
mode="pop"
|
||||
@success="onCaptchaSuccess"
|
||||
@error="onCaptchaError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, unref, computed, onUnmounted } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { username, password, mobile, code } from '@/sheep/validate/form';
|
||||
import { showAuthModal, getSmsCode, useSmsTimer } from '@/sheep/hooks/useModal';
|
||||
import AuthUtil from '@/sheep/api/system/auth';
|
||||
import SVerify from '@/sheep/components/s-verify/s-verify.vue';
|
||||
import { navigateAfterLogin } from '@/sheep/helper/login-redirect';
|
||||
|
||||
const accountLoginRef = ref(null);
|
||||
const smsLoginRef = ref(null);
|
||||
const verifyRef = ref(null);
|
||||
|
||||
const emits = defineEmits(['onConfirm']);
|
||||
|
||||
const props = defineProps({
|
||||
agreeStatus: {
|
||||
type: [Boolean, null],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 登录方式:account(账号) 或 sms(短信)
|
||||
const loginType = ref('account');
|
||||
|
||||
// 验证码相关
|
||||
const appStore = sheep.$store('app');
|
||||
const captchaType = ref('blockPuzzle');
|
||||
const captchaEnable = computed(() => appStore.captchaEnable !== false);
|
||||
const pendingLoginData = ref(null);
|
||||
|
||||
// 短信验证码计时器
|
||||
const { timer: smsTimer, cleanup } = useSmsTimer('smsLogin');
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// 账号登录数据
|
||||
const accountState = reactive({
|
||||
model: {
|
||||
username: '', // 账号
|
||||
password: '', // 密码
|
||||
captchaCode: '', // 图形验证码
|
||||
captchaVerification: '', // 验证码验证结果
|
||||
},
|
||||
rules: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
// 短信登录数据
|
||||
const smsState = reactive({
|
||||
model: {
|
||||
mobile: '', // 手机号
|
||||
code: '', // 验证码
|
||||
captchaVerification: '', // 验证码验证结果
|
||||
},
|
||||
rules: {
|
||||
mobile,
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
// 是否可以发送短信验证码
|
||||
const canSendSms = computed(() => {
|
||||
return smsTimer.value === '获取验证码' && props.agreeStatus === true;
|
||||
});
|
||||
|
||||
// 切换登录方式
|
||||
function switchLoginType(type) {
|
||||
loginType.value = type;
|
||||
}
|
||||
|
||||
// 显示验证码弹窗
|
||||
function showCaptcha() {
|
||||
if (verifyRef.value) {
|
||||
verifyRef.value.show();
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码验证成功回调
|
||||
function onCaptchaSuccess(data) {
|
||||
if (pendingLoginData.value) {
|
||||
pendingLoginData.value.captchaVerification = data.captchaVerification;
|
||||
if (loginType.value === 'account') {
|
||||
accountState.model.captchaVerification = data.captchaVerification;
|
||||
} else {
|
||||
smsState.model.captchaVerification = data.captchaVerification;
|
||||
}
|
||||
|
||||
// 执行待定的登录请求
|
||||
if (loginType.value === 'account') {
|
||||
performAccountLogin(pendingLoginData.value);
|
||||
} else {
|
||||
performSmsLogin(pendingLoginData.value);
|
||||
}
|
||||
|
||||
pendingLoginData.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码验证失败回调
|
||||
function onCaptchaError(error) {
|
||||
sheep.$helper.toast(error?.message || '验证码验证失败');
|
||||
pendingLoginData.value = null;
|
||||
}
|
||||
|
||||
// 处理账号登录点击
|
||||
async function handleAccountLogin() {
|
||||
// 表单验证
|
||||
const validate = await unref(accountLoginRef)
|
||||
.validate()
|
||||
.catch((error) => {
|
||||
console.log('表单验证失败: ', error);
|
||||
});
|
||||
if (!validate) return;
|
||||
|
||||
accountState.model.captchaVerification = '';
|
||||
|
||||
// 检查协议状态
|
||||
if (props.agreeStatus !== true) {
|
||||
emits('onConfirm', true);
|
||||
sheep.$helper.toast('请先勾选协议');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果启用验证码,先显示验证码
|
||||
if (captchaEnable.value) {
|
||||
pendingLoginData.value = { ...accountState.model };
|
||||
showCaptcha();
|
||||
} else {
|
||||
// 直接登录
|
||||
performAccountLogin(accountState.model);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行账号登录
|
||||
async function performAccountLogin(loginData) {
|
||||
try {
|
||||
const { code } = await AuthUtil.login(loginData);
|
||||
if (code === 0) {
|
||||
sheep.$helper.toast('登录成功');
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (error) {
|
||||
sheep.$helper.toast(error.message || '登录失败');
|
||||
} finally {
|
||||
accountState.model.captchaVerification = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 处理短信登录点击
|
||||
async function handleSmsLogin() {
|
||||
// 表单验证
|
||||
const validate = await unref(smsLoginRef)
|
||||
.validate()
|
||||
.catch((error) => {
|
||||
console.log('表单验证失败: ', error);
|
||||
});
|
||||
if (!validate) return;
|
||||
|
||||
smsState.model.captchaVerification = '';
|
||||
|
||||
// 检查协议状态
|
||||
if (props.agreeStatus !== true) {
|
||||
emits('onConfirm', true);
|
||||
sheep.$helper.toast('请先勾选协议');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果启用验证码,先显示验证码
|
||||
if (captchaEnable.value) {
|
||||
pendingLoginData.value = { ...smsState.model };
|
||||
showCaptcha();
|
||||
} else {
|
||||
// 直接登录
|
||||
performSmsLogin(smsState.model);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行短信登录
|
||||
async function performSmsLogin(loginData) {
|
||||
try {
|
||||
const { code } = await AuthUtil.smsLogin(loginData);
|
||||
if (code === 0) {
|
||||
sheep.$helper.toast('登录成功');
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (error) {
|
||||
sheep.$helper.toast(error.message || '登录失败');
|
||||
} finally {
|
||||
smsState.model.captchaVerification = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 检查协议并获取短信验证码
|
||||
async function checkAgreementAndGetSmsCode() {
|
||||
// 检查协议状态
|
||||
if (props.agreeStatus !== true) {
|
||||
emits('onConfirm', true);
|
||||
sheep.$helper.toast('请先勾选协议');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用现有的getSmsCode函数
|
||||
getSmsCode('smsLogin', smsState.model.mobile);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../index.scss';
|
||||
|
||||
// 标题样式增强
|
||||
.head-title {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--ui-BG-Main);
|
||||
}
|
||||
}
|
||||
|
||||
.head-title-active {
|
||||
color: var(--ui-BG-Main);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--ui-BG-Main);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.head-title-animation {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖登录按钮样式,使用主题色
|
||||
.login-btn-start {
|
||||
background: var(--ui-BG-Main) !important;
|
||||
border: none !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--ui-BG-Main-1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖验证码按钮样式,使用主题色
|
||||
.code-btn-start {
|
||||
border: 2rpx solid var(--ui-BG-Main) !important;
|
||||
color: var(--ui-BG-Main) !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--ui-BG-Main-tag) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖协议链接样式,使用主题色
|
||||
:deep(.tcp-text) {
|
||||
color: var(--ui-BG-Main) !important;
|
||||
}
|
||||
|
||||
// 确保表单输入框焦点状态也使用主题色
|
||||
:deep(.uni-easyinput__content-input) {
|
||||
&:focus {
|
||||
border-color: var(--ui-BG-Main) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保uni-forms的错误提示等也使用合适的颜色
|
||||
:deep(.uni-forms-item__error) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
|
||||
// 按钮悬停状态优化
|
||||
.login-btn-start {
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:active {
|
||||
background: var(--ui-BG-Main-1) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.code-btn-start {
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:active {
|
||||
background: var(--ui-BG-Main-tag) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
sheep/components/s-auth-modal/index.scss
Normal file
154
sheep/components/s-auth-modal/index.scss
Normal file
@@ -0,0 +1,154 @@
|
||||
@keyframes title-animation {
|
||||
0% {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
100% {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
padding: 50rpx 34rpx;
|
||||
min-height: 500rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
}
|
||||
|
||||
.head-box {
|
||||
.head-title {
|
||||
min-width: 160rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
.head-title-active {
|
||||
width: 160rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
.head-title-animation {
|
||||
animation-name: title-animation;
|
||||
animation-duration: 0.1s;
|
||||
animation-timing-function: ease-out;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
.head-title-line {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 1rpx;
|
||||
height: 34rpx;
|
||||
background-color: #e4e7ed;
|
||||
position: absolute;
|
||||
left: -30rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.head-subtitle {
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
color: #afb6c0;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
// .code-btn[disabled] {
|
||||
// background-color: #fff;
|
||||
// }
|
||||
.code-btn-start {
|
||||
width: 160rpx;
|
||||
height: 56rpx;
|
||||
line-height: normal;
|
||||
border: 2rpx solid var(--ui-BG-Main, #0055A2) !important;
|
||||
border-radius: 28rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
color: var(--ui-BG-Main, #0055A2) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.forgot-btn {
|
||||
width: 160rpx;
|
||||
line-height: 56rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.login-btn-start {
|
||||
width: 158rpx;
|
||||
height: 56rpx;
|
||||
line-height: normal;
|
||||
background: var(--ui-BG-Main, #0055A2) !important;
|
||||
border-radius: 28rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #fff !important;
|
||||
border: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 20rpx;
|
||||
margin: 40rpx auto;
|
||||
width: 200rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.auto-login-box {
|
||||
width: 100%;
|
||||
.auto-login-btn {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 50%;
|
||||
margin: 0 30rpx;
|
||||
}
|
||||
.auto-login-img {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-box {
|
||||
margin: 80rpx auto 0;
|
||||
.protocol-check {
|
||||
transform: scale(0.7);
|
||||
}
|
||||
.agreement-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #999999;
|
||||
.tcp-text {
|
||||
color: var(--ui-BG-Main, #0055A2) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
.editPwd-btn-box {
|
||||
.save-btn {
|
||||
width: 690rpx;
|
||||
line-height: 70rpx;
|
||||
background: var(--ui-BG-Main, #0055A2) !important;
|
||||
border-radius: 35rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.forgot-btn {
|
||||
width: 690rpx;
|
||||
line-height: 70rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
203
sheep/components/s-avatar/s-avatar.vue
Normal file
203
sheep/components/s-avatar/s-avatar.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
默认头像组件
|
||||
参考 zt-vue-element 的 CropperAvatar 实现
|
||||
支持默认头像逻辑:头像URL优先,否则显示用户昵称首字母
|
||||
-->
|
||||
<template>
|
||||
<view class="avatar-container" @click="handleClick">
|
||||
<!-- 有头像时显示图片 -->
|
||||
<image
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:style="avatarStyle"
|
||||
class="avatar-image"
|
||||
mode="aspectFill"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- 无头像时显示首字母头像 -->
|
||||
<view
|
||||
v-else
|
||||
class="avatar-initial"
|
||||
:style="[avatarStyle, initialStyle]"
|
||||
>
|
||||
{{ initialText }}
|
||||
</view>
|
||||
|
||||
<!-- 编辑按钮 -->
|
||||
<view v-if="editable && !readonly" class="edit-mask">
|
||||
<text class="edit-icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
// 头像URL
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 用户昵称,用于生成首字母
|
||||
nickname: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 头像尺寸
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 80
|
||||
},
|
||||
// 是否可编辑
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否只读
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 背景色(用于首字母头像)
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: '#0055A2'
|
||||
},
|
||||
// 文字色(用于首字母头像)
|
||||
textColor: {
|
||||
type: String,
|
||||
default: '#ffffff'
|
||||
},
|
||||
// 自定义初始字符
|
||||
initial: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 事件定义
|
||||
const emit = defineEmits(['click', 'error'])
|
||||
|
||||
// 响应式数据
|
||||
const avatarUrl = ref(props.src)
|
||||
const imageError = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const avatarStyle = computed(() => {
|
||||
const size = typeof props.size === 'number' ? `${props.size}rpx` : props.size
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%'
|
||||
}
|
||||
})
|
||||
|
||||
const initialStyle = computed(() => ({
|
||||
backgroundColor: props.bgColor,
|
||||
color: props.textColor,
|
||||
fontSize: `${Math.floor(props.size * 0.4)}rpx`,
|
||||
fontWeight: 'bold'
|
||||
}))
|
||||
|
||||
// 生成初始字符逻辑
|
||||
const initialText = computed(() => {
|
||||
// 如果提供了自定义初始字符,使用它
|
||||
if (props.initial) {
|
||||
return props.initial.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
// 如果有昵称,取首字母
|
||||
if (props.nickname) {
|
||||
// 处理中文和英文
|
||||
const firstChar = props.nickname.charAt(0)
|
||||
// 如果是中文,直接使用
|
||||
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
|
||||
return firstChar
|
||||
}
|
||||
// 如果是英文,转大写
|
||||
return firstChar.toUpperCase()
|
||||
}
|
||||
|
||||
// 默认返回用户图标
|
||||
return '用'
|
||||
})
|
||||
|
||||
// 监听src变化
|
||||
watch(() => props.src, (newSrc) => {
|
||||
avatarUrl.value = newSrc
|
||||
imageError.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
// 方法
|
||||
const handleClick = () => {
|
||||
if (!props.readonly) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
avatarUrl.value = '' // 清空URL,显示首字母头像
|
||||
emit('error')
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
avatarUrl.value = props.src
|
||||
imageError.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover .edit-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--theme-primary, #0055A2);
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.edit-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
color: #ffffff;
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
173
sheep/components/s-count-down/s-count-down.vue
Normal file
173
sheep/components/s-count-down/s-count-down.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<view class="time" :style="justifyLeft">
|
||||
<text class="" v-if="tipText">{{ tipText }}</text>
|
||||
<text class="styleAll p6" v-if="isDay === true"
|
||||
:style="{background:bgColor.bgColor,color:bgColor.Color}">{{ day }}{{bgColor.isDay?'天':''}}</text>
|
||||
<text class="timeTxt" v-if="dayText"
|
||||
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ dayText }}</text>
|
||||
<text class="styleAll" :class='isCol?"timeCol":""'
|
||||
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ hour }}</text>
|
||||
<text class="timeTxt" v-if="hourText" :class='isCol?"whit":""'
|
||||
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ hourText }}</text>
|
||||
<text class="styleAll" :class='isCol?"timeCol":""'
|
||||
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ minute }}</text>
|
||||
<text class="timeTxt" v-if="minuteText" :class='isCol?"whit":""'
|
||||
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ minuteText }}</text>
|
||||
<text class="styleAll" :class='isCol?"timeCol":""'
|
||||
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ second }}</text>
|
||||
<text class="timeTxt" v-if="secondText">{{ secondText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "countDown",
|
||||
props: {
|
||||
justifyLeft: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
//距离开始提示文字
|
||||
tipText: {
|
||||
type: String,
|
||||
default: "倒计时"
|
||||
},
|
||||
dayText: {
|
||||
type: String,
|
||||
default: "天"
|
||||
},
|
||||
hourText: {
|
||||
type: String,
|
||||
default: "时"
|
||||
},
|
||||
minuteText: {
|
||||
type: String,
|
||||
default: "分"
|
||||
},
|
||||
secondText: {
|
||||
type: String,
|
||||
default: "秒"
|
||||
},
|
||||
datatime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isDay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isCol: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
bgColor: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
day: "00",
|
||||
hour: "00",
|
||||
minute: "00",
|
||||
second: "00"
|
||||
};
|
||||
},
|
||||
created: function() {
|
||||
this.show_time();
|
||||
},
|
||||
mounted: function() {},
|
||||
methods: {
|
||||
show_time: function() {
|
||||
let that = this;
|
||||
|
||||
function runTime() {
|
||||
//时间函数
|
||||
let intDiff = that.datatime - Date.parse(new Date()) / 1000; //获取数据中的时间戳的时间差;
|
||||
let day = 0,
|
||||
hour = 0,
|
||||
minute = 0,
|
||||
second = 0;
|
||||
if (intDiff > 0) {
|
||||
//转换时间
|
||||
if (that.isDay === true) {
|
||||
day = Math.floor(intDiff / (60 * 60 * 24));
|
||||
} else {
|
||||
day = 0;
|
||||
}
|
||||
hour = Math.floor(intDiff / (60 * 60)) - day * 24;
|
||||
minute = Math.floor(intDiff / 60) - day * 24 * 60 - hour * 60;
|
||||
second =
|
||||
Math.floor(intDiff) -
|
||||
day * 24 * 60 * 60 -
|
||||
hour * 60 * 60 -
|
||||
minute * 60;
|
||||
if (hour <= 9) hour = "0" + hour;
|
||||
if (minute <= 9) minute = "0" + minute;
|
||||
if (second <= 9) second = "0" + second;
|
||||
that.day = day;
|
||||
that.hour = hour;
|
||||
that.minute = minute;
|
||||
that.second = second;
|
||||
} else {
|
||||
that.day = "00";
|
||||
that.hour = "00";
|
||||
that.minute = "00";
|
||||
that.second = "00";
|
||||
}
|
||||
}
|
||||
runTime();
|
||||
setInterval(runTime, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p6 {
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.styleAll {
|
||||
/* color: #fff; */
|
||||
font-size: 24rpx;
|
||||
height: 36rpx;
|
||||
line-height: 36rpx;
|
||||
border-radius: 6rpx;
|
||||
text-align: center;
|
||||
/* padding: 0 6rpx; */
|
||||
}
|
||||
|
||||
.timeTxt {
|
||||
text-align: center;
|
||||
/* width: 16rpx; */
|
||||
height: 36rpx;
|
||||
line-height: 36rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.whit {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #fc4141;
|
||||
margin: 0 4rpx;
|
||||
}
|
||||
|
||||
.timeCol {
|
||||
/* width: 40rpx;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
text-align:center;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 24rpx; */
|
||||
color: #E93323;
|
||||
}
|
||||
</style>
|
||||
93
sheep/components/s-empty/s-empty.vue
Normal file
93
sheep/components/s-empty/s-empty.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<view
|
||||
class="ss-flex-col ss-col-center ss-row-center empty-box"
|
||||
:style="[{ paddingTop: paddingTop + 'rpx' }]"
|
||||
>
|
||||
<view class=""><image class="empty-icon" :src="icon" mode="widthFix"></image></view>
|
||||
<view class="empty-text ss-m-t-28 ss-m-b-40">
|
||||
<text v-if="text !== ''">{{ text }}</text>
|
||||
</view>
|
||||
<button class="ss-reset-button empty-btn" v-if="showAction" @tap="clickAction">
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import sheep from '@/sheep';
|
||||
/**
|
||||
* 容器组件 - 装修组件的样式容器
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
// 图标
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 描述
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 是否显示button
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// button 文字
|
||||
actionText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 链接
|
||||
actionUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 间距
|
||||
paddingTop: {
|
||||
type: String,
|
||||
default: '260',
|
||||
},
|
||||
//主题色
|
||||
buttonColor: {
|
||||
type: String,
|
||||
default: 'var(--ui-BG-Main)',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['clickAction']);
|
||||
|
||||
function clickAction() {
|
||||
if (props.actionUrl !== '') {
|
||||
sheep.$router.go(props.actionUrl);
|
||||
}
|
||||
emits('clickAction');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-box {
|
||||
width: 100%;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 240rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
width: 320rpx;
|
||||
height: 70rpx;
|
||||
border: 2rpx solid v-bind('buttonColor');
|
||||
border-radius: 35rpx;
|
||||
font-weight: 500;
|
||||
color: v-bind('buttonColor');
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
273
sheep/components/s-layout/s-layout.vue
Normal file
273
sheep/components/s-layout/s-layout.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<view
|
||||
class="page-app"
|
||||
:class="['theme-' + sys?.mode, 'main-' + sys?.theme, 'font-' + sys?.fontSize]"
|
||||
>
|
||||
<view class="page-main" :style="[bgMain]">
|
||||
<!-- <!– 顶部导航栏-情况1:默认通用顶部导航栏 –>-->
|
||||
<!-- <su-navbar-->
|
||||
<!-- v-if="navbar === 'normal'"-->
|
||||
<!-- :title="title"-->
|
||||
<!-- statusBar-->
|
||||
<!-- :color="color"-->
|
||||
<!-- :tools="tools"-->
|
||||
<!-- :opacityBgUi="opacityBgUi"-->
|
||||
<!-- @search="(e) => emits('search', e)"-->
|
||||
<!-- :defaultSearch="defaultSearch"-->
|
||||
<!-- />-->
|
||||
|
||||
<!-- <!– 顶部导航栏-情况2:装修组件导航栏-标准 –>-->
|
||||
<!-- <s-custom-navbar-->
|
||||
<!-- v-else-if="navbar === 'custom' && navbarMode === 'normal'"-->
|
||||
<!-- :data="navbarStyle"-->
|
||||
<!-- :showLeftButton="showLeftButton"-->
|
||||
<!-- />-->
|
||||
<view class="page-body" :style="[bgBody]">
|
||||
<!-- <!– 顶部导航栏-情况3:沉浸式头部 –>-->
|
||||
<!-- <su-inner-navbar v-if="navbar === 'inner'" :title="title" />-->
|
||||
<!-- <view-->
|
||||
<!-- v-if="navbar === 'inner'"-->
|
||||
<!-- :style="[{ paddingTop: sheep?.$platform?.navbar + 'px' }]"-->
|
||||
<!-- ></view>-->
|
||||
|
||||
<!-- <!– 顶部导航栏-情况4:装修组件导航栏-沉浸式 –>-->
|
||||
<!-- <s-custom-navbar-->
|
||||
<!-- v-if="navbar === 'custom' && navbarMode === 'inner'"-->
|
||||
<!-- :data="navbarStyle"-->
|
||||
<!-- :showLeftButton="showLeftButton"-->
|
||||
<!-- />-->
|
||||
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot />
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<s-tabbar-uview v-if="tabbar !== ''" :path="tabbar" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-modal">
|
||||
<!-- 全局快捷入口 -->
|
||||
<!-- <s-menu-tools /> -->
|
||||
<CompanyDeptDialog />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 模板组件 - 提供页面公共组件,属性,方法
|
||||
*/
|
||||
import { computed, onMounted } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
// #ifdef MP-WEIXIN
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app';
|
||||
// #endif
|
||||
|
||||
// 引入新的基于 uview-plus 的底部导航组件
|
||||
import STabbarUview from '@/sheep/components/s-tabbar-uview/s-tabbar-uview.vue';
|
||||
import CompanyDeptDialog from '@/sheep/components/company-dept-dialog/company-dept-dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
navbar: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
opacityBgUi: {
|
||||
type: String,
|
||||
default: 'bg-white',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
tools: {
|
||||
type: String,
|
||||
default: 'title',
|
||||
},
|
||||
keyword: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
navbarStyle: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
styleType: '',
|
||||
type: '',
|
||||
color: '',
|
||||
src: '',
|
||||
list: [],
|
||||
alwaysShow: 0,
|
||||
}),
|
||||
},
|
||||
bgStyle: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
src: '',
|
||||
color: 'var(--ui-BG-1)',
|
||||
}),
|
||||
},
|
||||
tabbar: {
|
||||
type: [String, Boolean],
|
||||
default: '',
|
||||
},
|
||||
onShareAppMessage: {
|
||||
type: [Boolean, Object],
|
||||
default: true,
|
||||
},
|
||||
leftWidth: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
rightWidth: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
defaultSearch: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
//展示返回按钮
|
||||
showLeftButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['search']);
|
||||
|
||||
const sysStore = sheep.$store('sys');
|
||||
const userStore = sheep.$store('user');
|
||||
const appStore = sheep.$store('app');
|
||||
const modalStore = sheep.$store('modal');
|
||||
const sys = computed(() => sysStore);
|
||||
|
||||
// 导航栏模式(因为有自定义导航栏 需要计算)
|
||||
const navbarMode = computed(() => {
|
||||
if (props.navbar === 'normal' || props.navbarStyle.styleType === 'normal') {
|
||||
return 'normal';
|
||||
}
|
||||
return 'inner';
|
||||
});
|
||||
|
||||
// 背景1
|
||||
const bgMain = computed(() => {
|
||||
if (navbarMode.value === 'inner') {
|
||||
return {
|
||||
background: `${props.bgStyle.backgroundColor || props.bgStyle.color} url(${sheep.$url.cdn(
|
||||
props.bgStyle.backgroundImage,
|
||||
)}) no-repeat top center / 100% auto`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 背景2
|
||||
const bgBody = computed(() => {
|
||||
if (navbarMode.value === 'normal') {
|
||||
return {
|
||||
background: `${props.bgStyle.backgroundColor || props.bgStyle.color} url(${sheep.$url.cdn(
|
||||
props.bgStyle.backgroundImage,
|
||||
)}) no-repeat top center / 100% auto`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 分享信息
|
||||
const shareInfo = computed(() => {
|
||||
if (props.onShareAppMessage === true) {
|
||||
return sheep.$platform.share.getShareInfo();
|
||||
} else {
|
||||
if (!isEmpty(props.onShareAppMessage)) {
|
||||
sheep.$platform.share.updateShareInfo(props.onShareAppMessage);
|
||||
return props.onShareAppMessage;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.showShareMenu({
|
||||
withShareTicket: true,
|
||||
menus: ['shareAppMessage', 'shareTimeline'],
|
||||
});
|
||||
// 微信小程序分享好友
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: shareInfo.value.title,
|
||||
path: shareInfo.value.forward.path,
|
||||
imageUrl: shareInfo.value.image,
|
||||
};
|
||||
});
|
||||
// 微信小程序分享朋友圈
|
||||
onShareTimeline(() => {
|
||||
return {
|
||||
title: shareInfo.value.title,
|
||||
query: shareInfo.value.forward.path,
|
||||
imageUrl: shareInfo.value.image,
|
||||
};
|
||||
});
|
||||
// #endif
|
||||
|
||||
// 组件中使用 onMounted 监听页面加载,不是页面组件不使用 onShow
|
||||
onMounted(()=>{
|
||||
if (!isEmpty(shareInfo.value)) {
|
||||
sheep.$platform.share.updateShareInfo(shareInfo.value);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-app {
|
||||
position: relative;
|
||||
color: var(--ui-TC);
|
||||
background-color: var(--ui-BG-1) !important;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.page-main {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.page-body {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-img {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
sheep/components/s-tabbar-uview/s-tabbar-uview.vue
Normal file
167
sheep/components/s-tabbar-uview/s-tabbar-uview.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<view class="tabbar-container" v-if="showTabbar">
|
||||
<u-tabbar
|
||||
:value="currentPath"
|
||||
:fixed="true"
|
||||
:placeholder="true"
|
||||
:safeAreaInsetBottom="true"
|
||||
:border="true"
|
||||
:activeColor="activeColor"
|
||||
:inactiveColor="inactiveColor"
|
||||
:bgColor="bgColor"
|
||||
@change="handleTabbarChange"
|
||||
>
|
||||
<u-tabbar-item
|
||||
v-for="(item, index) in tabbarList"
|
||||
:key="index"
|
||||
:name="item.pagePath"
|
||||
:text="item.text"
|
||||
>
|
||||
<template #active-icon>
|
||||
<view class="custom-icon active">
|
||||
<u-icon
|
||||
:name="item.activeIcon || item.icon"
|
||||
size="24"
|
||||
:color="activeColor"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<template #inactive-icon>
|
||||
<view class="custom-icon">
|
||||
<u-icon
|
||||
:name="item.icon"
|
||||
size="24"
|
||||
:color="inactiveColor"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</u-tabbar-item>
|
||||
</u-tabbar>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 底部导航配置
|
||||
const tabbarList = ref([
|
||||
{
|
||||
pagePath: '/pages/index/menu',
|
||||
text: '菜单',
|
||||
icon: 'home',
|
||||
activeIcon: 'home-fill'
|
||||
},
|
||||
{
|
||||
pagePath: '/pages/index/user',
|
||||
text: '我的',
|
||||
icon: 'account',
|
||||
activeIcon: 'account-fill'
|
||||
}
|
||||
])
|
||||
|
||||
// 颜色配置
|
||||
const activeColor = '#0055A2'
|
||||
const inactiveColor = '#999999'
|
||||
const bgColor = '#ffffff'
|
||||
|
||||
// 当前路径
|
||||
const currentPath = computed(() => {
|
||||
return props.path || getCurrentPath()
|
||||
})
|
||||
|
||||
// 是否显示tabbar
|
||||
const showTabbar = computed(() => {
|
||||
const currentRoute = getCurrentPath()
|
||||
return tabbarList.value.some(item => item.pagePath === currentRoute)
|
||||
})
|
||||
|
||||
// 获取当前页面路径
|
||||
const getCurrentPath = () => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
return '/' + currentPage.route
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 处理tabbar切换
|
||||
const handleTabbarChange = (name) => {
|
||||
if (name !== currentPath.value) {
|
||||
uni.switchTab({
|
||||
url: name,
|
||||
fail: (error) => {
|
||||
console.error('switchTab failed:', error)
|
||||
// 如果switchTab失败,使用普通跳转
|
||||
uni.navigateTo({
|
||||
url: name,
|
||||
fail: (navError) => {
|
||||
console.error('navigateTo failed:', navError)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 隐藏原生tabBar
|
||||
uni.hideTabBar({
|
||||
animation: false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabbar-container {
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.custom-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.u-tabbar) {
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #ebedf0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.u-tabbar-item {
|
||||
padding: 4px 0 8px;
|
||||
|
||||
&__text {
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 为不同主题色适配
|
||||
:deep(.u-tabbar .u-tabbar-item--active .u-tabbar-item__text) {
|
||||
color: #0055A2;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
304
sheep/components/s-uploader/choose-and-upload-file.js
Normal file
304
sheep/components/s-uploader/choose-and-upload-file.js
Normal file
@@ -0,0 +1,304 @@
|
||||
'use strict';
|
||||
import FileApi from '@/sheep/api/infra/file';
|
||||
|
||||
const ERR_MSG_OK = 'chooseAndUploadFile:ok';
|
||||
const ERR_MSG_FAIL = 'chooseAndUploadFile:fail';
|
||||
|
||||
function chooseImage(opts) {
|
||||
const {
|
||||
count,
|
||||
sizeType = ['original', 'compressed'],
|
||||
sourceType = ['album', 'camera'],
|
||||
extension,
|
||||
} = opts;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count,
|
||||
sizeType,
|
||||
sourceType,
|
||||
extension,
|
||||
success(res) {
|
||||
resolve(normalizeChooseAndUploadFileRes(res, 'image'));
|
||||
},
|
||||
fail(res) {
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chooseVideo(opts) {
|
||||
const { camera, compressed, maxDuration, sourceType = ['album', 'camera'], extension } = opts;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseVideo({
|
||||
camera,
|
||||
compressed,
|
||||
maxDuration,
|
||||
sourceType,
|
||||
extension,
|
||||
success(res) {
|
||||
const { tempFilePath, duration, size, height, width } = res;
|
||||
resolve(
|
||||
normalizeChooseAndUploadFileRes(
|
||||
{
|
||||
errMsg: 'chooseVideo:ok',
|
||||
tempFilePaths: [tempFilePath],
|
||||
tempFiles: [
|
||||
{
|
||||
name: (res.tempFile && res.tempFile.name) || '',
|
||||
path: tempFilePath,
|
||||
size,
|
||||
type: (res.tempFile && res.tempFile.type) || '',
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
fileType: 'video',
|
||||
cloudPath: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
'video',
|
||||
),
|
||||
);
|
||||
},
|
||||
fail(res) {
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chooseAll(opts) {
|
||||
const { count, extension } = opts;
|
||||
return new Promise((resolve, reject) => {
|
||||
let chooseFile = uni.chooseFile;
|
||||
if (typeof wx !== 'undefined' && typeof wx.chooseMessageFile === 'function') {
|
||||
chooseFile = wx.chooseMessageFile;
|
||||
}
|
||||
if (typeof chooseFile !== 'function') {
|
||||
return reject({
|
||||
errMsg: ERR_MSG_FAIL + ' 请指定 type 类型,该平台仅支持选择 image 或 video。',
|
||||
});
|
||||
}
|
||||
chooseFile({
|
||||
type: 'all',
|
||||
count,
|
||||
extension,
|
||||
success(res) {
|
||||
resolve(normalizeChooseAndUploadFileRes(res));
|
||||
},
|
||||
fail(res) {
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseFile:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChooseAndUploadFileRes(res, fileType) {
|
||||
res.tempFiles.forEach((item, index) => {
|
||||
if (!item.name) {
|
||||
item.name = item.path.substring(item.path.lastIndexOf('/') + 1);
|
||||
}
|
||||
if (fileType) {
|
||||
item.fileType = fileType;
|
||||
}
|
||||
item.cloudPath = Date.now() + '_' + index + item.name.substring(item.name.lastIndexOf('.'));
|
||||
});
|
||||
if (!res.tempFilePaths) {
|
||||
res.tempFilePaths = res.tempFiles.map((file) => file.path);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function readFile(uniFile) {
|
||||
// 微信小程序
|
||||
if (uni.getFileSystemManager) {
|
||||
const fs = uni.getFileSystemManager();
|
||||
return fs.readFileSync(uniFile.path);
|
||||
}
|
||||
// H5 等
|
||||
return uniFile.arrayBuffer();
|
||||
}
|
||||
|
||||
function uploadCloudFiles(files, max = 5, onUploadProgress) {
|
||||
files = JSON.parse(JSON.stringify(files));
|
||||
const len = files.length;
|
||||
let count = 0;
|
||||
let self = this;
|
||||
return new Promise((resolve) => {
|
||||
while (count < max) {
|
||||
next();
|
||||
}
|
||||
|
||||
function next() {
|
||||
let cur = count++;
|
||||
if (cur >= len) {
|
||||
!files.find((item) => !item.url && !item.errMsg) && resolve(files);
|
||||
return;
|
||||
}
|
||||
const fileItem = files[cur];
|
||||
const index = self.files.findIndex((v) => v.uuid === fileItem.uuid);
|
||||
fileItem.url = '';
|
||||
delete fileItem.errMsg;
|
||||
|
||||
uniCloud
|
||||
.uploadFile({
|
||||
filePath: fileItem.path,
|
||||
cloudPath: fileItem.cloudPath,
|
||||
fileType: fileItem.fileType,
|
||||
onUploadProgress: (res) => {
|
||||
res.index = index;
|
||||
onUploadProgress && onUploadProgress(res);
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
fileItem.url = res.fileID;
|
||||
fileItem.index = index;
|
||||
if (cur < len) {
|
||||
next();
|
||||
}
|
||||
})
|
||||
.catch((res) => {
|
||||
fileItem.errMsg = res.errMsg || res.message;
|
||||
fileItem.index = index;
|
||||
if (cur < len) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFilesFromPath(path, directory = '') {
|
||||
// 目的:用于微信小程序,选择图片时,只有 path
|
||||
return uploadFiles(
|
||||
Promise.resolve({
|
||||
tempFiles: [
|
||||
{
|
||||
path,
|
||||
type: 'image/jpeg',
|
||||
name: path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
directory,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadFiles(choosePromise, { onChooseFile, onUploadProgress, directory }) {
|
||||
// 获取选择的文件
|
||||
const res = await choosePromise;
|
||||
// 处理文件选择回调
|
||||
let files = res.tempFiles || [];
|
||||
if (onChooseFile) {
|
||||
const customChooseRes = onChooseFile(res);
|
||||
if (typeof customChooseRes !== 'undefined') {
|
||||
files = await Promise.resolve(customChooseRes);
|
||||
if (typeof files === 'undefined') {
|
||||
files = res.tempFiles || []; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是前端直连上传
|
||||
if (UPLOAD_TYPE.CLIENT === import.meta.env.SHOPRO_UPLOAD_TYPE) {
|
||||
// 为上传创建一组 Promise
|
||||
const uploadPromises = files.map(async (file) => {
|
||||
try {
|
||||
// 1.1 获取文件预签名地址
|
||||
const { data: presignedInfo } = await FileApi.getFilePresignedUrl(file.name, directory);
|
||||
// 1.2 获取二进制文件对象
|
||||
const fileBuffer = await readFile(file);
|
||||
|
||||
// 返回上传的 Promise
|
||||
return new Promise((resolve, reject) => {
|
||||
// 1.3. 上传文件到 S3
|
||||
uni.request({
|
||||
url: presignedInfo.uploadUrl,
|
||||
method: 'PUT',
|
||||
header: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
data: fileBuffer,
|
||||
success: (res) => {
|
||||
// 1.4. 记录文件信息到后端(异步)
|
||||
createFile(presignedInfo, file);
|
||||
// 1.5. 重新赋值
|
||||
file.url = presignedInfo.url;
|
||||
resolve(file);
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有上传完成
|
||||
return await Promise.all(uploadPromises); // 返回已上传的文件列表
|
||||
} else {
|
||||
// 后端上传
|
||||
for (let file of files) {
|
||||
const { data } = await FileApi.uploadFile(file.path, directory);
|
||||
file.url = data;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAndUploadFile(
|
||||
opts = {
|
||||
type: 'all',
|
||||
directory: undefined,
|
||||
},
|
||||
) {
|
||||
if (opts.type === 'image') {
|
||||
return uploadFiles(chooseImage(opts), opts);
|
||||
} else if (opts.type === 'video') {
|
||||
return uploadFiles(chooseVideo(opts), opts);
|
||||
}
|
||||
return uploadFiles(chooseAll(opts), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile(vo, file) {
|
||||
const fileVo = {
|
||||
configId: vo.configId,
|
||||
url: vo.url,
|
||||
path: vo.path,
|
||||
name: file.name,
|
||||
type: file.fileType,
|
||||
size: file.size,
|
||||
};
|
||||
FileApi.createFile(fileVo);
|
||||
return fileVo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传类型
|
||||
*/
|
||||
const UPLOAD_TYPE = {
|
||||
// 客户端直接上传(只支持S3服务)
|
||||
CLIENT: 'client',
|
||||
// 客户端发送到后端上传
|
||||
SERVER: 'server',
|
||||
};
|
||||
|
||||
export { chooseAndUploadFile, uploadCloudFiles, uploadFilesFromPath };
|
||||
677
sheep/components/s-uploader/s-uploader.vue
Normal file
677
sheep/components/s-uploader/s-uploader.vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<!-- 文件上传,基于 upload-file 和 upload-image 实现 -->
|
||||
<template>
|
||||
<view class="uni-file-picker">
|
||||
<view v-if="title" class="uni-file-picker__header">
|
||||
<text class="file-title">{{ title }}</text>
|
||||
<text class="file-count">{{ filesList.length }}/{{ limitLength }}</text>
|
||||
</view>
|
||||
<view v-if="subtitle" class="file-subtitle">
|
||||
<view>{{ subtitle }}</view>
|
||||
</view>
|
||||
<upload-image
|
||||
v-if="fileMediatype === 'image' && showType === 'grid'"
|
||||
:readonly="readonly"
|
||||
:image-styles="imageStyles"
|
||||
:files-list="url"
|
||||
:limit="limitLength"
|
||||
:disablePreview="disablePreview"
|
||||
:delIcon="delIcon"
|
||||
@uploadFiles="uploadFiles"
|
||||
@choose="choose"
|
||||
@delFile="delFile"
|
||||
>
|
||||
<slot>
|
||||
<view class="is-add">
|
||||
<image :src="imgsrc" class="add-icon"></image>
|
||||
</view>
|
||||
</slot>
|
||||
</upload-image>
|
||||
<upload-file
|
||||
v-if="fileMediatype !== 'image' || showType !== 'grid'"
|
||||
:readonly="readonly"
|
||||
:list-styles="listStyles"
|
||||
:files-list="filesList"
|
||||
:showType="showType"
|
||||
:delIcon="delIcon"
|
||||
@uploadFiles="uploadFiles"
|
||||
@choose="choose"
|
||||
@delFile="delFile"
|
||||
>
|
||||
<slot><button type="primary" size="mini">选择文件</button></slot>
|
||||
</upload-file>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { chooseAndUploadFile, uploadCloudFiles } from './choose-and-upload-file.js';
|
||||
import { get_extname, get_files_and_is_max, get_file_data } from './utils.js';
|
||||
import uploadImage from './upload-image.vue';
|
||||
import uploadFile from './upload-file.vue';
|
||||
import sheep from '@/sheep';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
let fileInput = null;
|
||||
/**
|
||||
* FilePicker 文件选择上传
|
||||
* @description 文件选择上传组件,可以选择图片、视频等任意文件并上传到当前绑定的服务空间
|
||||
* @tutorial https://ext.dcloud.net.cn/plugin?id=4079
|
||||
* @property {Object|Array} value 组件数据,通常用来回显 ,类型由return-type属性决定
|
||||
* @property {String|Array} url url数据
|
||||
* @property {Boolean} disabled = [true|false] 组件禁用
|
||||
* @value true 禁用
|
||||
* @value false 取消禁用
|
||||
* @property {Boolean} readonly = [true|false] 组件只读,不可选择,不显示进度,不显示删除按钮
|
||||
* @value true 只读
|
||||
* @value false 取消只读
|
||||
* @property {Boolean} disable-preview = [true|false] 禁用图片预览,仅 mode:grid 时生效
|
||||
* @value true 禁用图片预览
|
||||
* @value false 取消禁用图片预览
|
||||
* @property {Boolean} del-icon = [true|false] 是否显示删除按钮
|
||||
* @value true 显示删除按钮
|
||||
* @value false 不显示删除按钮
|
||||
* @property {Boolean} auto-upload = [true|false] 是否自动上传,值为true则只触发@select,可自行上传
|
||||
* @value true 自动上传
|
||||
* @value false 取消自动上传
|
||||
* @property {Number|String} limit 最大选择个数 ,h5 会自动忽略多选的部分
|
||||
* @property {String} title 组件标题,右侧显示上传计数
|
||||
* @property {String} mode = [list|grid] 选择文件后的文件列表样式
|
||||
* @value list 列表显示
|
||||
* @value grid 宫格显示
|
||||
* @property {String} file-mediatype = [image|video|all] 选择文件类型
|
||||
* @value image 只选择图片
|
||||
* @value video 只选择视频
|
||||
* @value all 选择所有文件
|
||||
* @property {Array} file-extname 选择文件后缀,根据 file-mediatype 属性而不同
|
||||
* @property {Object} list-style mode:list 时的样式
|
||||
* @property {Object} image-styles 选择文件后缀,根据 file-mediatype 属性而不同
|
||||
* @event {Function} select 选择文件后触发
|
||||
* @event {Function} progress 文件上传时触发
|
||||
* @event {Function} success 上传成功触发
|
||||
* @event {Function} fail 上传失败触发
|
||||
* @event {Function} delete 文件从列表移除时触发
|
||||
*/
|
||||
export default {
|
||||
name: 'sUploader',
|
||||
components: {
|
||||
uploadImage,
|
||||
uploadFile,
|
||||
},
|
||||
options: {
|
||||
virtualHost: true,
|
||||
},
|
||||
emits: ['select', 'success', 'fail', 'progress', 'delete', 'update:modelValue', 'update:url'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [Array, Object],
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
url: {
|
||||
type: [Array, String],
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disablePreview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
delIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 自动上传
|
||||
autoUpload: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 最大选择个数 ,h5只能限制单选或是多选
|
||||
limit: {
|
||||
type: [Number, String],
|
||||
default: 9,
|
||||
},
|
||||
// 列表样式 grid | list | list-card
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'grid',
|
||||
},
|
||||
// 选择文件类型 image/video/all
|
||||
fileMediatype: {
|
||||
type: String,
|
||||
default: 'image',
|
||||
},
|
||||
// 文件类型筛选
|
||||
fileExtname: {
|
||||
type: [Array, String],
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
listStyles: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
// 是否显示边框
|
||||
border: true,
|
||||
// 是否显示分隔线
|
||||
dividline: true,
|
||||
// 线条样式
|
||||
borderStyle: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
imageStyles: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
};
|
||||
},
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sizeType: {
|
||||
type: Array,
|
||||
default() {
|
||||
return ['original', 'compressed'];
|
||||
},
|
||||
},
|
||||
driver: {
|
||||
type: String,
|
||||
default: 'local', // local=本地 | oss | unicloud
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
localValue: [],
|
||||
imgsrc: sheep.$url.static('/static/img/shop/upload-camera.png'),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
handler(newVal, oldVal) {
|
||||
this.setValue(newVal, oldVal);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
returnType() {
|
||||
if (this.limit > 1) {
|
||||
return 'array';
|
||||
}
|
||||
return 'object';
|
||||
},
|
||||
filesList() {
|
||||
let files = [];
|
||||
this.files.forEach((v) => {
|
||||
files.push(v);
|
||||
});
|
||||
return files;
|
||||
},
|
||||
showType() {
|
||||
if (this.fileMediatype === 'image') {
|
||||
return this.mode;
|
||||
}
|
||||
return 'list';
|
||||
},
|
||||
limitLength() {
|
||||
if (this.returnType === 'object') {
|
||||
return 1;
|
||||
}
|
||||
if (!this.limit) {
|
||||
return 1;
|
||||
}
|
||||
if (this.limit >= 9) {
|
||||
return 9;
|
||||
}
|
||||
return this.limit;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.driver === 'local') {
|
||||
uniCloud.chooseAndUploadFile = chooseAndUploadFile;
|
||||
}
|
||||
this.form = this.getForm('uniForms');
|
||||
this.formItem = this.getForm('uniFormsItem');
|
||||
if (this.form && this.formItem) {
|
||||
if (this.formItem.name) {
|
||||
this.rename = this.formItem.name;
|
||||
this.form.inputChildrens.push(this);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 公开用户使用,清空文件
|
||||
* @param {Object} index
|
||||
*/
|
||||
clearFiles(index) {
|
||||
if (index !== 0 && !index) {
|
||||
this.files = [];
|
||||
this.$nextTick(() => {
|
||||
this.setEmit();
|
||||
});
|
||||
} else {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.setEmit();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 公开用户使用,继续上传
|
||||
*/
|
||||
upload() {
|
||||
let files = [];
|
||||
this.files.forEach((v, index) => {
|
||||
if (v.status === 'ready' || v.status === 'error') {
|
||||
files.push(Object.assign({}, v));
|
||||
}
|
||||
});
|
||||
return this.uploadFiles(files);
|
||||
},
|
||||
async setValue(newVal, oldVal) {
|
||||
const newData = async (v) => {
|
||||
const reg = /cloud:\/\/([\w.]+\/?)\S*/;
|
||||
let url = '';
|
||||
if (v.fileID) {
|
||||
url = v.fileID;
|
||||
} else {
|
||||
url = v.url;
|
||||
}
|
||||
if (reg.test(url)) {
|
||||
v.fileID = url;
|
||||
v.url = await this.getTempFileURL(url);
|
||||
}
|
||||
if (v.url) v.path = v.url;
|
||||
return v;
|
||||
};
|
||||
if (this.returnType === 'object') {
|
||||
if (newVal) {
|
||||
await newData(newVal);
|
||||
} else {
|
||||
newVal = {};
|
||||
}
|
||||
} else {
|
||||
if (!newVal) newVal = [];
|
||||
for (let i = 0; i < newVal.length; i++) {
|
||||
let v = newVal[i];
|
||||
await newData(v);
|
||||
}
|
||||
}
|
||||
this.localValue = newVal;
|
||||
if (this.form && this.formItem && !this.is_reset) {
|
||||
this.is_reset = false;
|
||||
this.formItem.setValue(this.localValue);
|
||||
}
|
||||
let filesData = Object.keys(newVal).length > 0 ? newVal : [];
|
||||
this.files = [].concat(filesData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择文件
|
||||
*/
|
||||
choose() {
|
||||
if (this.disabled) return;
|
||||
if (
|
||||
this.files.length >= Number(this.limitLength) &&
|
||||
this.showType !== 'grid' &&
|
||||
this.returnType === 'array'
|
||||
) {
|
||||
uni.showToast({
|
||||
title: `您最多选择 ${this.limitLength} 个文件`,
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.chooseFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择文件并上传
|
||||
*/
|
||||
async chooseFiles() {
|
||||
const _extname = get_extname(this.fileExtname);
|
||||
// 获取后缀
|
||||
await chooseAndUploadFile({
|
||||
type: this.fileMediatype,
|
||||
compressed: false,
|
||||
sizeType: this.sizeType,
|
||||
// TODO 如果为空,video 有问题
|
||||
extension: _extname.length > 0 ? _extname : undefined,
|
||||
count: this.limitLength - this.files.length, //默认9
|
||||
onChooseFile: this.chooseFileCallback,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.setProgress(progressEvent, progressEvent.index);
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.setSuccessAndError(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('选择失败', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择文件回调
|
||||
* @param {Object} res
|
||||
*/
|
||||
async chooseFileCallback(res) {
|
||||
const _extname = get_extname(this.fileExtname);
|
||||
const is_one =
|
||||
(Number(this.limitLength) === 1 && this.disablePreview && !this.disabled) ||
|
||||
this.returnType === 'object';
|
||||
// 如果这有一个文件 ,需要清空本地缓存数据
|
||||
if (is_one) {
|
||||
this.files = [];
|
||||
}
|
||||
|
||||
let { filePaths, files } = get_files_and_is_max(res, _extname);
|
||||
if (!(_extname && _extname.length > 0)) {
|
||||
filePaths = res.tempFilePaths;
|
||||
files = res.tempFiles;
|
||||
}
|
||||
|
||||
let currentData = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (this.limitLength - this.files.length <= 0) break;
|
||||
files[i].uuid = Date.now();
|
||||
let filedata = await get_file_data(files[i], this.fileMediatype);
|
||||
filedata.progress = 0;
|
||||
filedata.status = 'ready';
|
||||
this.files.push(filedata);
|
||||
currentData.push({
|
||||
...filedata,
|
||||
file: files[i],
|
||||
});
|
||||
}
|
||||
this.$emit('select', {
|
||||
tempFiles: currentData,
|
||||
tempFilePaths: filePaths,
|
||||
});
|
||||
res.tempFiles = files;
|
||||
// 停止自动上传
|
||||
if (!this.autoUpload) {
|
||||
res.tempFiles = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 批传
|
||||
* @param {Object} e
|
||||
*/
|
||||
uploadFiles(files) {
|
||||
files = [].concat(files);
|
||||
return uploadCloudFiles
|
||||
.call(this, files, 5, (res) => {
|
||||
this.setProgress(res, res.index, true);
|
||||
})
|
||||
.then((result) => {
|
||||
this.setSuccessAndError(result);
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 成功或失败
|
||||
*/
|
||||
async setSuccessAndError(res, fn) {
|
||||
let successData = [];
|
||||
let errorData = [];
|
||||
let tempFilePath = [];
|
||||
let errorTempFilePath = [];
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
const index = item.uuid ? this.files.findIndex((p) => p.uuid === item.uuid) : item.index;
|
||||
|
||||
if (index === -1 || !this.files) break;
|
||||
if (item.errMsg === 'request:fail') {
|
||||
this.files[index].url = item.url;
|
||||
this.files[index].status = 'error';
|
||||
this.files[index].errMsg = item.errMsg;
|
||||
// this.files[index].progress = -1
|
||||
errorData.push(this.files[index]);
|
||||
errorTempFilePath.push(this.files[index].url);
|
||||
} else {
|
||||
this.files[index].errMsg = '';
|
||||
this.files[index].fileID = item.url;
|
||||
const reg = /cloud:\/\/([\w.]+\/?)\S*/;
|
||||
if (reg.test(item.url)) {
|
||||
this.files[index].url = await this.getTempFileURL(item.url);
|
||||
} else {
|
||||
this.files[index].url = item.url;
|
||||
}
|
||||
|
||||
this.files[index].status = 'success';
|
||||
this.files[index].progress += 1;
|
||||
successData.push(this.files[index]);
|
||||
tempFilePath.push(this.files[index].fileID);
|
||||
}
|
||||
}
|
||||
|
||||
if (successData.length > 0) {
|
||||
this.setEmit();
|
||||
// 状态改变返回
|
||||
this.$emit('success', {
|
||||
tempFiles: this.backObject(successData),
|
||||
tempFilePaths: tempFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorData.length > 0) {
|
||||
this.$emit('fail', {
|
||||
tempFiles: this.backObject(errorData),
|
||||
tempFilePaths: errorTempFilePath,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取进度
|
||||
* @param {Object} progressEvent
|
||||
* @param {Object} index
|
||||
* @param {Object} type
|
||||
*/
|
||||
setProgress(progressEvent, index, type) {
|
||||
const fileLenth = this.files.length;
|
||||
const percentNum = (index / fileLenth) * 100;
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
let idx = index;
|
||||
if (!type) {
|
||||
idx = this.files.findIndex((p) => p.uuid === progressEvent.tempFile.uuid);
|
||||
}
|
||||
if (idx === -1 || !this.files[idx]) return;
|
||||
// fix by mehaotian 100 就会消失,-1 是为了让进度条消失
|
||||
this.files[idx].progress = percentCompleted - 1;
|
||||
// 上传中
|
||||
this.$emit('progress', {
|
||||
index: idx,
|
||||
progress: parseInt(percentCompleted),
|
||||
tempFile: this.files[idx],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param {Object} index
|
||||
*/
|
||||
delFile(index) {
|
||||
if (!isEmpty(this.files)) {
|
||||
this.$emit('delete', {
|
||||
tempFile: this.files[index],
|
||||
tempFilePath: this.files[index].url,
|
||||
});
|
||||
this.files.splice(index, 1);
|
||||
} else {
|
||||
this.$emit('delete', {
|
||||
tempFilePath: this.url,
|
||||
});
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.setEmit();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文件名和后缀
|
||||
* @param {Object} name
|
||||
*/
|
||||
getFileExt(name) {
|
||||
const last_len = name.lastIndexOf('.');
|
||||
const len = name.length;
|
||||
return {
|
||||
name: name.substring(0, last_len),
|
||||
ext: name.substring(last_len + 1, len),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理返回事件
|
||||
*/
|
||||
setEmit() {
|
||||
let data = [];
|
||||
let updateUrl = [];
|
||||
if (this.returnType === 'object') {
|
||||
data = this.backObject(this.files)[0];
|
||||
this.localValue = data ? data : null;
|
||||
updateUrl = data ? data.url : '';
|
||||
} else {
|
||||
data = this.backObject(this.files);
|
||||
if (!this.localValue) {
|
||||
this.localValue = [];
|
||||
}
|
||||
this.localValue = [...data];
|
||||
if (this.localValue.length > 0) {
|
||||
this.localValue.forEach((item) => {
|
||||
updateUrl.push(item.url);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.$emit('update:modelValue', this.localValue);
|
||||
this.$emit('update:url', updateUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理返回参数
|
||||
* @param {Object} files
|
||||
*/
|
||||
backObject(files) {
|
||||
let newFilesData = [];
|
||||
files.forEach((v) => {
|
||||
newFilesData.push({
|
||||
extname: v.extname,
|
||||
fileType: v.fileType,
|
||||
image: v.image,
|
||||
name: v.name,
|
||||
path: v.path,
|
||||
size: v.size,
|
||||
fileID: v.fileID,
|
||||
url: v.url,
|
||||
});
|
||||
});
|
||||
return newFilesData;
|
||||
},
|
||||
async getTempFileURL(fileList) {
|
||||
fileList = {
|
||||
fileList: [].concat(fileList),
|
||||
};
|
||||
const urls = await uniCloud.getTempFileURL(fileList);
|
||||
return urls.fileList[0].tempFileURL || '';
|
||||
},
|
||||
/**
|
||||
* 获取父元素实例
|
||||
*/
|
||||
getForm(name = 'uniForms') {
|
||||
let parent = this.$parent;
|
||||
let parentName = parent.$options.name;
|
||||
while (parentName !== name) {
|
||||
parent = parent.$parent;
|
||||
if (!parent) return false;
|
||||
parentName = parent.$options.name;
|
||||
}
|
||||
return parent;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.uni-file-picker {
|
||||
/* #ifndef APP-NVUE */
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
/* width: 100%; */
|
||||
/* #endif */
|
||||
/* flex: 1; */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.uni-file-picker__header {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 10px;
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.file-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.is-add {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.add-icon {
|
||||
width: 57rpx;
|
||||
height: 49rpx;
|
||||
}
|
||||
.file-subtitle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
width: 140rpx;
|
||||
height: 36rpx;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
background: rgba(#000, 0.3);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
335
sheep/components/s-uploader/upload-file.vue
Normal file
335
sheep/components/s-uploader/upload-file.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<view class="uni-file-picker__files">
|
||||
<view v-if="!readonly" class="files-button" @click="choose">
|
||||
<slot></slot>
|
||||
</view>
|
||||
<!-- :class="{'is-text-box':showType === 'list'}" -->
|
||||
<view v-if="list.length > 0" class="uni-file-picker__lists is-text-box" :style="borderStyle">
|
||||
<!-- ,'is-list-card':showType === 'list-card' -->
|
||||
|
||||
<view
|
||||
class="uni-file-picker__lists-box"
|
||||
v-for="(item, index) in list"
|
||||
:key="index"
|
||||
:class="{
|
||||
'files-border': index !== 0 && styles.dividline,
|
||||
}"
|
||||
:style="index !== 0 && styles.dividline && borderLineStyle"
|
||||
>
|
||||
<view class="uni-file-picker__item">
|
||||
<!-- :class="{'is-text-image':showType === 'list'}" -->
|
||||
<!-- <view class="files__image is-text-image">
|
||||
<image class="header-image" :src="item.logo" mode="aspectFit"></image>
|
||||
</view> -->
|
||||
<view class="files__name">{{ item.name }}</view>
|
||||
<view v-if="delIcon && !readonly" class="icon-del-box icon-files" @click="delFile(index)">
|
||||
<view class="icon-del icon-files"></view>
|
||||
<view class="icon-del rotate"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="(item.progress && item.progress !== 100) || item.progress === 0"
|
||||
class="file-picker__progress"
|
||||
>
|
||||
<progress
|
||||
class="file-picker__progress-item"
|
||||
:percent="item.progress === -1 ? 0 : item.progress"
|
||||
stroke-width="4"
|
||||
:backgroundColor="item.errMsg ? '#ff5a5f' : '#EBEBEB'"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="item.status === 'error'"
|
||||
class="file-picker__mask"
|
||||
@click.stop="uploadFiles(item, index)"
|
||||
>
|
||||
点击重试
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'uploadFile',
|
||||
emits: ['uploadFiles', 'choose', 'delFile'],
|
||||
props: {
|
||||
filesList: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
delIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
limit: {
|
||||
type: [Number, String],
|
||||
default: 9,
|
||||
},
|
||||
showType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
listStyles: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
// 是否显示边框
|
||||
border: true,
|
||||
// 是否显示分隔线
|
||||
dividline: true,
|
||||
// 线条样式
|
||||
borderStyle: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
list() {
|
||||
let files = [];
|
||||
this.filesList.forEach((v) => {
|
||||
files.push(v);
|
||||
});
|
||||
return files;
|
||||
},
|
||||
styles() {
|
||||
let styles = {
|
||||
border: true,
|
||||
dividline: true,
|
||||
'border-style': {},
|
||||
};
|
||||
return Object.assign(styles, this.listStyles);
|
||||
},
|
||||
borderStyle() {
|
||||
let { borderStyle, border } = this.styles;
|
||||
let obj = {};
|
||||
if (!border) {
|
||||
obj.border = 'none';
|
||||
} else {
|
||||
let width = (borderStyle && borderStyle.width) || 1;
|
||||
width = this.value2px(width);
|
||||
let radius = (borderStyle && borderStyle.radius) || 5;
|
||||
radius = this.value2px(radius);
|
||||
obj = {
|
||||
'border-width': width,
|
||||
'border-style': (borderStyle && borderStyle.style) || 'solid',
|
||||
'border-color': (borderStyle && borderStyle.color) || '#eee',
|
||||
'border-radius': radius,
|
||||
};
|
||||
}
|
||||
let classles = '';
|
||||
for (let i in obj) {
|
||||
classles += `${i}:${obj[i]};`;
|
||||
}
|
||||
return classles;
|
||||
},
|
||||
borderLineStyle() {
|
||||
let obj = {};
|
||||
let { borderStyle } = this.styles;
|
||||
if (borderStyle && borderStyle.color) {
|
||||
obj['border-color'] = borderStyle.color;
|
||||
}
|
||||
if (borderStyle && borderStyle.width) {
|
||||
let width = (borderStyle && borderStyle.width) || 1;
|
||||
let style = (borderStyle && borderStyle.style) || 0;
|
||||
if (typeof width === 'number') {
|
||||
width += 'px';
|
||||
} else {
|
||||
width = width.indexOf('px') ? width : width + 'px';
|
||||
}
|
||||
obj['border-width'] = width;
|
||||
|
||||
if (typeof style === 'number') {
|
||||
style += 'px';
|
||||
} else {
|
||||
style = style.indexOf('px') ? style : style + 'px';
|
||||
}
|
||||
obj['border-top-style'] = style;
|
||||
}
|
||||
let classles = '';
|
||||
for (let i in obj) {
|
||||
classles += `${i}:${obj[i]};`;
|
||||
}
|
||||
return classles;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
uploadFiles(item, index) {
|
||||
this.$emit('uploadFiles', {
|
||||
item,
|
||||
index,
|
||||
});
|
||||
},
|
||||
choose() {
|
||||
this.$emit('choose');
|
||||
},
|
||||
delFile(index) {
|
||||
this.$emit('delFile', index);
|
||||
},
|
||||
value2px(value) {
|
||||
if (typeof value === 'number') {
|
||||
value += 'px';
|
||||
} else {
|
||||
value = value.indexOf('px') !== -1 ? value : value + 'px';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.uni-file-picker__files {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.files-button {
|
||||
// border: 1px red solid;
|
||||
}
|
||||
|
||||
.uni-file-picker__lists {
|
||||
position: relative;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-picker__mask {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.uni-file-picker__lists-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.uni-file-picker__item {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
padding-right: 5px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.files-border {
|
||||
border-top: 1px #eee solid;
|
||||
}
|
||||
|
||||
.files__name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-right: 25px;
|
||||
/* #ifndef APP-NVUE */
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.icon-files {
|
||||
/* #ifndef APP-NVUE */
|
||||
position: static;
|
||||
background-color: initial;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
// .icon-files .icon-del {
|
||||
// background-color: #333;
|
||||
// width: 12px;
|
||||
// height: 1px;
|
||||
// }
|
||||
|
||||
.is-list-card {
|
||||
border: 1px #eee solid;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 2px 0px rgba(0, 0, 0, 0.1);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.files__image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.header-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.is-text-box {
|
||||
border: 1px #eee solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.is-text-image {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
position: absolute;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.icon-del-box {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
margin: auto 0;
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
bottom: 0;
|
||||
right: 5px;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
// border-radius: 50%;
|
||||
// background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 2;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.icon-del {
|
||||
width: 15px;
|
||||
height: 1px;
|
||||
background-color: #333;
|
||||
// border-radius: 1px;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
@media all and (min-width: 768px) {
|
||||
.uni-file-picker__files {
|
||||
max-width: 375px;
|
||||
}
|
||||
}
|
||||
|
||||
/* #endif */
|
||||
</style>
|
||||
306
sheep/components/s-uploader/upload-image.vue
Normal file
306
sheep/components/s-uploader/upload-image.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<view class="uni-file-picker__container">
|
||||
<view class="file-picker__box" v-for="(url, index) in list" :key="index" :style="boxStyle">
|
||||
<view class="file-picker__box-content" :style="borderStyle">
|
||||
<image
|
||||
class="file-image"
|
||||
:src="getImageUrl(url)"
|
||||
mode="aspectFill"
|
||||
@click.stop="previewImage(url, index)"
|
||||
></image>
|
||||
<view v-if="delIcon && !readonly" class="icon-del-box" @click.stop="delFile(index)">
|
||||
<view class="icon-del"></view>
|
||||
<view class="icon-del rotate"></view>
|
||||
</view>
|
||||
<!-- <view v-if="item.errMsg" class="file-picker__mask" @click.stop="uploadFiles(item, index)">
|
||||
点击重试
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="list.length < limit && !readonly" class="file-picker__box" :style="boxStyle">
|
||||
<view class="file-picker__box-content is-add" :style="borderStyle" @click="choose">
|
||||
<slot>
|
||||
<view class="icon-add"></view>
|
||||
<view class="icon-add rotate"></view>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import sheep from '@/sheep';
|
||||
export default {
|
||||
name: 'uploadImage',
|
||||
emits: ['uploadFiles', 'choose', 'delFile'],
|
||||
props: {
|
||||
filesList: {
|
||||
type: [Array, String],
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disablePreview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
limit: {
|
||||
type: [Number, String],
|
||||
default: 9,
|
||||
},
|
||||
imageStyles: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
border: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
delIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
list() {
|
||||
if (typeof this.filesList === 'string') {
|
||||
if (this.filesList) {
|
||||
return [this.filesList];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return this.filesList;
|
||||
},
|
||||
styles() {
|
||||
let styles = {
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
border: {},
|
||||
};
|
||||
return Object.assign(styles, this.imageStyles);
|
||||
},
|
||||
boxStyle() {
|
||||
const { width = 'auto', height = 'auto' } = this.styles;
|
||||
let obj = {};
|
||||
if (height === 'auto') {
|
||||
if (width !== 'auto') {
|
||||
obj.height = this.value2px(width);
|
||||
obj['padding-top'] = 0;
|
||||
} else {
|
||||
obj.height = 0;
|
||||
}
|
||||
} else {
|
||||
obj.height = this.value2px(height);
|
||||
obj['padding-top'] = 0;
|
||||
}
|
||||
|
||||
if (width === 'auto') {
|
||||
if (height !== 'auto') {
|
||||
obj.width = this.value2px(height);
|
||||
} else {
|
||||
obj.width = '33.3%';
|
||||
}
|
||||
} else {
|
||||
obj.width = this.value2px(width);
|
||||
}
|
||||
|
||||
let classles = '';
|
||||
for (let i in obj) {
|
||||
classles += `${i}:${obj[i]};`;
|
||||
}
|
||||
return classles;
|
||||
},
|
||||
borderStyle() {
|
||||
let { border } = this.styles;
|
||||
let obj = {};
|
||||
const widthDefaultValue = 1;
|
||||
const radiusDefaultValue = 3;
|
||||
if (typeof border === 'boolean') {
|
||||
obj.border = border ? '1px #eee solid' : 'none';
|
||||
} else {
|
||||
let width = (border && border.width) || widthDefaultValue;
|
||||
width = this.value2px(width);
|
||||
let radius = (border && border.radius) || radiusDefaultValue;
|
||||
radius = this.value2px(radius);
|
||||
obj = {
|
||||
'border-width': width,
|
||||
'border-style': (border && border.style) || 'solid',
|
||||
'border-color': (border && border.color) || '#eee',
|
||||
'border-radius': radius,
|
||||
};
|
||||
}
|
||||
let classles = '';
|
||||
for (let i in obj) {
|
||||
classles += `${i}:${obj[i]};`;
|
||||
}
|
||||
return classles;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getImageUrl(url) {
|
||||
if ('blob:http:' === url.substr(0, 10)) {
|
||||
return url;
|
||||
} else {
|
||||
return sheep.$url.cdn(url);
|
||||
}
|
||||
},
|
||||
uploadFiles(item, index) {
|
||||
this.$emit('uploadFiles', item);
|
||||
},
|
||||
choose() {
|
||||
this.$emit('choose');
|
||||
},
|
||||
delFile(index) {
|
||||
this.$emit('delFile', index);
|
||||
},
|
||||
previewImage(img, index) {
|
||||
let urls = [];
|
||||
if (Number(this.limit) === 1 && this.disablePreview && !this.disabled) {
|
||||
this.$emit('choose');
|
||||
}
|
||||
if (this.disablePreview) return;
|
||||
this.list.forEach((i) => {
|
||||
urls.push(this.getImageUrl(i));
|
||||
});
|
||||
|
||||
uni.previewImage({
|
||||
urls: urls,
|
||||
current: index,
|
||||
});
|
||||
},
|
||||
value2px(value) {
|
||||
if (typeof value === 'number') {
|
||||
value += 'px';
|
||||
} else {
|
||||
if (value.indexOf('%') === -1) {
|
||||
value = value.indexOf('px') !== -1 ? value : value + 'px';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.uni-file-picker__container {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
/* #endif */
|
||||
flex-wrap: wrap;
|
||||
margin: -5px;
|
||||
}
|
||||
|
||||
.file-picker__box {
|
||||
position: relative;
|
||||
// flex: 0 0 33.3%;
|
||||
width: 33.3%;
|
||||
height: 0;
|
||||
padding-top: 33.33%;
|
||||
/* #ifndef APP-NVUE */
|
||||
box-sizing: border-box;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.file-picker__box-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: 5px;
|
||||
border: 1px #eee solid;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-picker__progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* border: 1px red solid; */
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-picker__progress-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-picker__mask {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.file-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.is-add {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-add {
|
||||
width: 50px;
|
||||
height: 5px;
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
position: absolute;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.icon-del-box {
|
||||
/* #ifndef APP-NVUE */
|
||||
display: flex;
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 2;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.icon-del {
|
||||
width: 15px;
|
||||
height: 2px;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
109
sheep/components/s-uploader/utils.js
Normal file
109
sheep/components/s-uploader/utils.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 获取文件名和后缀
|
||||
* @param {String} name
|
||||
*/
|
||||
export const get_file_ext = (name) => {
|
||||
const last_len = name.lastIndexOf('.');
|
||||
const len = name.length;
|
||||
return {
|
||||
name: name.substring(0, last_len),
|
||||
ext: name.substring(last_len + 1, len),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取扩展名
|
||||
* @param {Array} fileExtname
|
||||
*/
|
||||
export const get_extname = (fileExtname) => {
|
||||
if (!Array.isArray(fileExtname)) {
|
||||
let extname = fileExtname.replace(/([\[\]])/g, '');
|
||||
return extname.split(',');
|
||||
} else {
|
||||
return fileExtname;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件和检测是否可选
|
||||
*/
|
||||
export const get_files_and_is_max = (res, _extname) => {
|
||||
let filePaths = [];
|
||||
let files = [];
|
||||
if (!_extname || _extname.length === 0) {
|
||||
return {
|
||||
filePaths,
|
||||
files,
|
||||
};
|
||||
}
|
||||
res.tempFiles.forEach((v) => {
|
||||
let fileFullName = get_file_ext(v.name);
|
||||
const extname = fileFullName.ext.toLowerCase();
|
||||
if (_extname.indexOf(extname) !== -1) {
|
||||
files.push(v);
|
||||
filePaths.push(v.path);
|
||||
}
|
||||
});
|
||||
if (files.length !== res.tempFiles.length) {
|
||||
uni.showToast({
|
||||
title: `当前选择了${res.tempFiles.length}个文件 ,${
|
||||
res.tempFiles.length - files.length
|
||||
} 个文件格式不正确`,
|
||||
icon: 'none',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filePaths,
|
||||
files,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取图片信息
|
||||
* @param {Object} filepath
|
||||
*/
|
||||
export const get_file_info = (filepath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getImageInfo({
|
||||
src: filepath,
|
||||
success(res) {
|
||||
resolve(res);
|
||||
},
|
||||
fail(err) {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 获取封装数据
|
||||
*/
|
||||
export const get_file_data = async (files, type = 'image') => {
|
||||
// 最终需要上传数据库的数据
|
||||
let fileFullName = get_file_ext(files.name);
|
||||
const extname = fileFullName.ext.toLowerCase();
|
||||
let filedata = {
|
||||
name: files.name,
|
||||
uuid: files.uuid,
|
||||
extname: extname || '',
|
||||
cloudPath: files.cloudPath,
|
||||
fileType: files.fileType,
|
||||
url: files.url || files.path,
|
||||
size: files.size, //单位是字节
|
||||
image: {},
|
||||
path: files.path,
|
||||
video: {},
|
||||
};
|
||||
if (type === 'image') {
|
||||
const imageinfo = await get_file_info(files.path);
|
||||
delete filedata.video;
|
||||
filedata.image.width = imageinfo.width;
|
||||
filedata.image.height = imageinfo.height;
|
||||
filedata.image.location = imageinfo.path;
|
||||
} else {
|
||||
delete filedata.image;
|
||||
}
|
||||
return filedata;
|
||||
};
|
||||
0
sheep/components/s-verify/s-verify-points.vue
Normal file
0
sheep/components/s-verify/s-verify-points.vue
Normal file
0
sheep/components/s-verify/s-verify-slide.vue
Normal file
0
sheep/components/s-verify/s-verify-slide.vue
Normal file
569
sheep/components/s-verify/s-verify.vue
Normal file
569
sheep/components/s-verify/s-verify.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<view>
|
||||
<view v-if="visible" class="s-verify-mask" @touchmove.stop.prevent>
|
||||
<view class="s-verify-wrapper" @tap.stop>
|
||||
<view class="s-verify-header">
|
||||
<text class="s-verify-title">安全验证</text>
|
||||
<view class="s-verify-close" @tap="handleClose">×</view>
|
||||
</view>
|
||||
|
||||
<view class="s-verify-body">
|
||||
<view class="s-verify-image" :style="imageStyle">
|
||||
<image
|
||||
v-if="backgroundImage"
|
||||
class="s-verify-image-bg"
|
||||
:src="backgroundImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image
|
||||
v-if="showBlock"
|
||||
class="s-verify-image-block"
|
||||
:style="blockStyle"
|
||||
:src="blockImage"
|
||||
mode="widthFix"
|
||||
/>
|
||||
|
||||
<view class="s-verify-refresh" @tap="refreshCaptcha">
|
||||
<text>↻</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="s-verify-loading">加载中...</view>
|
||||
</view>
|
||||
|
||||
<view v-if="tipMessage" :class="['s-verify-tip', tipClass]">
|
||||
{{ tipMessage }}
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="s-verify-slider"
|
||||
ref="sliderRef"
|
||||
@touchstart.stop.prevent="onDragStart"
|
||||
@touchmove.stop.prevent="onDragMove"
|
||||
@touchend.stop.prevent="onDragEnd"
|
||||
@mousedown.stop="onMouseDown"
|
||||
>
|
||||
<view class="s-verify-slider-track" />
|
||||
<view class="s-verify-slider-fill" :style="sliderFillStyle" />
|
||||
<view
|
||||
class="s-verify-slider-handle"
|
||||
:class="{
|
||||
's-verify-slider-success': success,
|
||||
's-verify-slider-fail': failAnimate
|
||||
}"
|
||||
:style="sliderHandleStyle"
|
||||
>
|
||||
<text class="s-verify-handle-icon">{{ success ? '✔' : '›' }}</text>
|
||||
</view>
|
||||
<text class="s-verify-slider-text">{{ sliderHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import AuthUtil from '@/sheep/api/system/auth';
|
||||
import { aesEncrypt } from '@/sheep/components/s-verify/utils/aes';
|
||||
|
||||
const props = defineProps({
|
||||
captchaType: {
|
||||
type: String,
|
||||
default: 'blockPuzzle',
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'pop',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['success', 'error']);
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
const failAnimate = ref(false);
|
||||
const verifying = ref(false);
|
||||
const tipMessage = ref('');
|
||||
const sliderRef = ref(null);
|
||||
|
||||
const sliderWidth = 310;
|
||||
const sliderHeight = 40;
|
||||
const blockWidth = Math.floor((sliderWidth * 47) / 310);
|
||||
const imageHeight = 155;
|
||||
|
||||
const dragState = reactive({
|
||||
startClientX: 0,
|
||||
baseOffset: 0,
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
const captchaState = reactive({
|
||||
originalImageBase64: '',
|
||||
jigsawImageBase64: '',
|
||||
token: '',
|
||||
secretKey: '',
|
||||
});
|
||||
|
||||
const handleOffset = ref(0);
|
||||
const maxOffset = computed(() => Math.max(sliderWidth - blockWidth, 0));
|
||||
|
||||
const showBlock = computed(() => props.captchaType === 'blockPuzzle' && blockImage.value);
|
||||
const backgroundImage = computed(() =>
|
||||
captchaState.originalImageBase64
|
||||
? `data:image/png;base64,${captchaState.originalImageBase64}`
|
||||
: ''
|
||||
);
|
||||
const blockImage = computed(() =>
|
||||
captchaState.jigsawImageBase64
|
||||
? `data:image/png;base64,${captchaState.jigsawImageBase64}`
|
||||
: ''
|
||||
);
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
width: `${sliderWidth}px`,
|
||||
height: `${imageHeight}px`,
|
||||
}));
|
||||
|
||||
const blockStyle = computed(() => ({
|
||||
width: `${blockWidth}px`,
|
||||
height: `${imageHeight}px`,
|
||||
transform: `translateX(${handleOffset.value}px)`,
|
||||
}));
|
||||
|
||||
const sliderHandleStyle = computed(() => ({
|
||||
width: `${blockWidth}px`,
|
||||
height: `${sliderHeight}px`,
|
||||
transform: `translateX(${handleOffset.value}px)`,
|
||||
}));
|
||||
|
||||
const sliderFillStyle = computed(() => ({
|
||||
width: `${handleOffset.value > 0 ? Math.min(handleOffset.value + blockWidth, sliderWidth) : 0}px`,
|
||||
}));
|
||||
|
||||
const sliderHint = computed(() => {
|
||||
if (success.value) {
|
||||
return '验证通过';
|
||||
}
|
||||
if (loading.value) {
|
||||
return '验证码加载中';
|
||||
}
|
||||
return '请按住滑块,拖动完成拼图';
|
||||
});
|
||||
|
||||
const tipClass = computed(() => {
|
||||
if (success.value) return 's-verify-tip-success';
|
||||
if (failAnimate.value) return 's-verify-tip-error';
|
||||
return '';
|
||||
});
|
||||
|
||||
let closeTimer = null;
|
||||
let mouseMoveListener = null;
|
||||
let mouseUpListener = null;
|
||||
|
||||
function show() {
|
||||
if (visible.value) return;
|
||||
visible.value = true;
|
||||
resetState();
|
||||
nextTick(() => {
|
||||
loadCaptcha();
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
cleanupMouseListeners();
|
||||
clearCloseTimer();
|
||||
}
|
||||
|
||||
function refreshCaptcha() {
|
||||
if (loading.value) return;
|
||||
resetSlider();
|
||||
loadCaptcha();
|
||||
}
|
||||
|
||||
function resetSlider() {
|
||||
handleOffset.value = 0;
|
||||
dragState.baseOffset = 0;
|
||||
dragState.startClientX = 0;
|
||||
dragState.isDragging = false;
|
||||
success.value = false;
|
||||
failAnimate.value = false;
|
||||
tipMessage.value = '';
|
||||
verifying.value = false;
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
captchaState.originalImageBase64 = '';
|
||||
captchaState.jigsawImageBase64 = '';
|
||||
captchaState.token = '';
|
||||
captchaState.secretKey = '';
|
||||
resetSlider();
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AuthUtil.getCaptchaCode({ captchaType: props.captchaType });
|
||||
if (res && (res.repCode === '0000' || res.code === 0)) {
|
||||
const data = res.repData || res.data || {};
|
||||
captchaState.originalImageBase64 = data.originalImageBase64 || '';
|
||||
captchaState.jigsawImageBase64 = data.jigsawImageBase64 || '';
|
||||
captchaState.token = data.token || '';
|
||||
captchaState.secretKey = data.secretKey || '';
|
||||
} else {
|
||||
tipMessage.value = res?.repMsg || res?.msg || '验证码获取失败,请重试';
|
||||
emits('error', res);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('captcha load error', error);
|
||||
tipMessage.value = '验证码加载异常,请稍后再试';
|
||||
emits('error', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event) {
|
||||
if (loading.value || success.value) return;
|
||||
dragState.isDragging = true;
|
||||
dragState.startClientX = getClientX(event);
|
||||
dragState.baseOffset = handleOffset.value;
|
||||
failAnimate.value = false;
|
||||
tipMessage.value = '';
|
||||
}
|
||||
|
||||
function onDragMove(event) {
|
||||
if (!dragState.isDragging) return;
|
||||
const currentX = getClientX(event);
|
||||
const delta = currentX - dragState.startClientX;
|
||||
let next = dragState.baseOffset + delta;
|
||||
if (next < 0) next = 0;
|
||||
if (next > maxOffset.value) next = maxOffset.value;
|
||||
handleOffset.value = next;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (!dragState.isDragging) return;
|
||||
dragState.isDragging = false;
|
||||
submitVerification();
|
||||
}
|
||||
|
||||
function onMouseDown(event) {
|
||||
if (event?.button !== 0) return;
|
||||
if (typeof window !== 'undefined') {
|
||||
mouseMoveListener = (ev) => {
|
||||
onDragMove(ev);
|
||||
ev.preventDefault();
|
||||
};
|
||||
mouseUpListener = (ev) => {
|
||||
cleanupMouseListeners();
|
||||
onDragEnd(ev);
|
||||
};
|
||||
window.addEventListener('mousemove', mouseMoveListener);
|
||||
window.addEventListener('mouseup', mouseUpListener);
|
||||
}
|
||||
onDragStart(event);
|
||||
}
|
||||
|
||||
function cleanupMouseListeners() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (mouseMoveListener) {
|
||||
window.removeEventListener('mousemove', mouseMoveListener);
|
||||
mouseMoveListener = null;
|
||||
}
|
||||
if (mouseUpListener) {
|
||||
window.removeEventListener('mouseup', mouseUpListener);
|
||||
mouseUpListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVerification() {
|
||||
if (verifying.value) return;
|
||||
verifying.value = true;
|
||||
failAnimate.value = false;
|
||||
|
||||
const point = {
|
||||
x: Math.round(handleOffset.value),
|
||||
y: 5.0,
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = captchaState.secretKey
|
||||
? aesEncrypt(JSON.stringify(point), captchaState.secretKey)
|
||||
: JSON.stringify(point);
|
||||
|
||||
const res = await AuthUtil.verifyCaptcha({
|
||||
captchaType: props.captchaType,
|
||||
token: captchaState.token,
|
||||
pointJson: payload,
|
||||
});
|
||||
|
||||
if (res && (res.repCode === '0000' || res.code === 0)) {
|
||||
success.value = true;
|
||||
tipMessage.value = '验证成功';
|
||||
const captchaVerification = captchaState.secretKey
|
||||
? aesEncrypt(
|
||||
`${captchaState.token}---${JSON.stringify(point)}`,
|
||||
captchaState.secretKey,
|
||||
)
|
||||
: `${captchaState.token}---${JSON.stringify(point)}`;
|
||||
|
||||
emits('success', {
|
||||
captchaVerification,
|
||||
token: captchaState.token,
|
||||
point,
|
||||
});
|
||||
|
||||
clearCloseTimer();
|
||||
closeTimer = setTimeout(() => {
|
||||
handleClose();
|
||||
}, 800);
|
||||
} else {
|
||||
handleFail(res?.repMsg || res?.msg || '验证失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('captcha verify error', error);
|
||||
handleFail('网络异常,请重试');
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFail(message) {
|
||||
failAnimate.value = true;
|
||||
tipMessage.value = message;
|
||||
emits('error', message);
|
||||
setTimeout(() => {
|
||||
failAnimate.value = false;
|
||||
}, 400);
|
||||
setTimeout(() => {
|
||||
refreshCaptcha();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function getClientX(event) {
|
||||
if (event?.touches && event.touches.length) {
|
||||
return event.touches[0].clientX;
|
||||
}
|
||||
if (event?.changedTouches && event.changedTouches.length) {
|
||||
return event.changedTouches[0].clientX;
|
||||
}
|
||||
return event?.clientX || 0;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMouseListeners();
|
||||
clearCloseTimer();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
close: handleClose,
|
||||
refresh: refreshCaptcha,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.s-verify-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.s-verify-wrapper {
|
||||
width: 340px;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.s-verify-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.s-verify-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.s-verify-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
color: #999999;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.s-verify-body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.s-verify-image {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.s-verify-image-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.s-verify-image-block {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.s-verify-refresh {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.s-verify-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.s-verify-tip {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.s-verify-tip-success {
|
||||
color: #52c41a;
|
||||
background: rgba(82, 196, 26, 0.12);
|
||||
}
|
||||
|
||||
.s-verify-tip-error {
|
||||
color: #ff4d4f;
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
}
|
||||
|
||||
.s-verify-slider {
|
||||
position: relative;
|
||||
width: 310px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: #f2f3f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.s-verify-slider-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 20px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.s-verify-slider-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: var(--ui-BG-Main, #409eff);
|
||||
border-radius: 20px;
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
.s-verify-slider-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 6px 14px rgba(25, 87, 170, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1f2d3d;
|
||||
transition: transform 0.05s linear;
|
||||
}
|
||||
|
||||
.s-verify-slider-success {
|
||||
background: #52c41a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.s-verify-slider-fail {
|
||||
animation: s-verify-shake 0.3s;
|
||||
}
|
||||
|
||||
.s-verify-handle-icon {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.s-verify-slider-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes s-verify-shake {
|
||||
0% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
sheep/components/s-verify/utils/aes.js
Normal file
17
sheep/components/s-verify/utils/aes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
/**
|
||||
* 使用 AES-ECB 模式 + PKCS7 填充对字符串进行加密
|
||||
* @param {string} word - 需要加密的明文
|
||||
* @param {string} [keyWord='XwKsGlMcdPMEhR1B'] - 加密密钥,默认为 aj-captcha 默认密钥
|
||||
* @returns {string} - 加密后的密文
|
||||
*/
|
||||
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
|
||||
const key = CryptoJS.enc.Utf8.parse(keyWord);
|
||||
const srcs = CryptoJS.enc.Utf8.parse(word);
|
||||
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
});
|
||||
return encrypted.toString();
|
||||
}
|
||||
227
sheep/components/s-verify/utils/index.js
Normal file
227
sheep/components/s-verify/utils/index.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 验证码组件工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成随机整数
|
||||
* @param {number} min 最小值
|
||||
* @param {number} max 最大值
|
||||
* @returns {number} 随机整数
|
||||
*/
|
||||
export function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机颜色
|
||||
* @returns {string} 十六进制颜色值
|
||||
*/
|
||||
export function randomColor() {
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
* @param {number} length 字符串长度
|
||||
* @param {string} chars 字符集
|
||||
* @returns {string} 随机字符串
|
||||
*/
|
||||
export function randomString(length = 4, chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678') {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两点距离
|
||||
* @param {number} x1 点1 x坐标
|
||||
* @param {number} y1 点1 y坐标
|
||||
* @param {number} x2 点2 x坐标
|
||||
* @param {number} y2 点2 y坐标
|
||||
* @returns {number} 距离
|
||||
*/
|
||||
export function getDistance(x1, y1, x2, y2) {
|
||||
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断点是否在矩形区域内
|
||||
* @param {number} x 点x坐标
|
||||
* @param {number} y 点y坐标
|
||||
* @param {Object} rect 矩形对象 {x, y, width, height}
|
||||
* @returns {boolean} 是否在区域内
|
||||
*/
|
||||
export function isPointInRect(x, y, rect) {
|
||||
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码图片拼图
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas上下文
|
||||
* @param {number} x x坐标
|
||||
* @param {number} y y坐标
|
||||
* @param {number} r 圆角半径
|
||||
* @param {number} PI 圆周率
|
||||
*/
|
||||
export function drawPath(ctx, x, y, r, PI) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.arc(x + r / 2, y - r + 2, r, 0, 2 * PI);
|
||||
ctx.lineTo(x + r, y);
|
||||
ctx.arc(x + r + r / 2, y + r / 2, r, 1.5 * PI, 0.5 * PI);
|
||||
ctx.lineTo(x + r, y + r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arc(x + r / 2, y + r + r / 2, r, PI, 0);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片像素数据
|
||||
* @param {string} imgSrc 图片源
|
||||
* @returns {Promise<ImageData>} 图片像素数据
|
||||
*/
|
||||
export function getImageData(imgSrc) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = imgSrc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建干扰线
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas上下文
|
||||
* @param {number} width 画布宽度
|
||||
* @param {number} height 画布高度
|
||||
* @param {number} lineCount 干扰线数量
|
||||
*/
|
||||
export function drawInterferenceLines(ctx, width, height, lineCount = 8) {
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
ctx.strokeStyle = randomColor();
|
||||
ctx.lineWidth = randomInt(1, 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(randomInt(0, width), randomInt(0, height));
|
||||
ctx.lineTo(randomInt(0, width), randomInt(0, height));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建干扰点
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas上下文
|
||||
* @param {number} width 画布宽度
|
||||
* @param {number} height 画布高度
|
||||
* @param {number} pointCount 干扰点数量
|
||||
*/
|
||||
export function drawInterferencePoints(ctx, width, height, pointCount = 100) {
|
||||
for (let i = 0; i < pointCount; i++) {
|
||||
ctx.fillStyle = randomColor();
|
||||
ctx.beginPath();
|
||||
ctx.arc(randomInt(0, width), randomInt(0, height), randomInt(1, 2), 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func 要执行的函数
|
||||
* @param {number} delay 延迟时间
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export function throttle(func, delay) {
|
||||
let timer = null;
|
||||
return function(...args) {
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
timer = null;
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func 要执行的函数
|
||||
* @param {number} delay 延迟时间
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export function debounce(func, delay) {
|
||||
let timer = null;
|
||||
return function(...args) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置画布
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas上下文
|
||||
* @param {number} width 画布宽度
|
||||
* @param {number} height 画布高度
|
||||
*/
|
||||
export function resetCanvas(ctx, width, height) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素的绝对位置
|
||||
* @param {Element} element DOM元素
|
||||
* @returns {Object} {left, top}
|
||||
*/
|
||||
export function getElementPosition(element) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
while (element) {
|
||||
left += element.offsetLeft;
|
||||
top += element.offsetTop;
|
||||
element = element.offsetParent;
|
||||
}
|
||||
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端触摸事件坐标获取
|
||||
* @param {Event} e 事件对象
|
||||
* @returns {Object} {x, y}
|
||||
*/
|
||||
export function getTouchPosition(e) {
|
||||
const touch = e.touches && e.touches.length > 0 ? e.touches[0] : e.changedTouches[0];
|
||||
return {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取鼠标或触摸位置
|
||||
* @param {Event} e 事件对象
|
||||
* @returns {Object} {x, y}
|
||||
*/
|
||||
export function getEventPosition(e) {
|
||||
if (e.touches) {
|
||||
return getTouchPosition(e);
|
||||
}
|
||||
return {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
};
|
||||
}
|
||||
|
||||
export { aesEncrypt } from './aes';
|
||||
Reference in New Issue
Block a user