初始化移动端提交

This commit is contained in:
chenbowen
2025-09-30 00:08:23 +08:00
parent 08784ca8f3
commit f2ffc65094
406 changed files with 55626 additions and 93 deletions

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

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

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

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

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

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

View 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;
}
}

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

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

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

View 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]">
<!-- &lt;!&ndash; 顶部导航栏-情况1默认通用顶部导航栏 &ndash;&gt;-->
<!-- <su-navbar-->
<!-- v-if="navbar === 'normal'"-->
<!-- :title="title"-->
<!-- statusBar-->
<!-- :color="color"-->
<!-- :tools="tools"-->
<!-- :opacityBgUi="opacityBgUi"-->
<!-- @search="(e) => emits('search', e)"-->
<!-- :defaultSearch="defaultSearch"-->
<!-- />-->
<!-- &lt;!&ndash; 顶部导航栏-情况2装修组件导航栏-标准 &ndash;&gt;-->
<!-- <s-custom-navbar-->
<!-- v-else-if="navbar === 'custom' && navbarMode === 'normal'"-->
<!-- :data="navbarStyle"-->
<!-- :showLeftButton="showLeftButton"-->
<!-- />-->
<view class="page-body" :style="[bgBody]">
<!-- &lt;!&ndash; 顶部导航栏-情况3沉浸式头部 &ndash;&gt;-->
<!-- <su-inner-navbar v-if="navbar === 'inner'" :title="title" />-->
<!-- <view-->
<!-- v-if="navbar === 'inner'"-->
<!-- :style="[{ paddingTop: sheep?.$platform?.navbar + 'px' }]"-->
<!-- ></view>-->
<!-- &lt;!&ndash; 顶部导航栏-情况4装修组件导航栏-沉浸式 &ndash;&gt;-->
<!-- <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>

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

View 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 };

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

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

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

View 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;
};

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

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

View 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';