初始化项目模板

This commit is contained in:
yangyao committed 2026-01-04 17:53:40 +08:00
1 parent 80fd6a1d4b
commit b3a69a1f27
69 files changed
+11088

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
<template>
<!-- 区域号码选择 -->
<up-popup :show="isShow" round="20" closeable @close="close">
<view class="container flex flex-col px-[20px]">
<view class="title flex items-center justify-center mx-[15px]">
<text class="txt text-[16px] py-[15px] font-bold">{{ t('areaCode') }}</text>
</view>
<view class="search-box my-[10px]">
<up-search v-model="keyword" :showAction="false" :placeholder="t('areaCodePlace')" @change="handleKeywordChange"></up-search>
</view>
<!-- 主列表 -->
<view class="index-list relative overflow-hidden">
<scroll-view scroll-y :scroll-into-view="scrollIntoId" class="main-list h-[70vh] overflow-hidden" @scroll="handleScroll">
<template v-if="isEmpty(keyword)">
<view v-for="(group, index) in list" :key="index" :id="group.groupKey" class="group-container relative">
<!-- 吸顶标题 -->
<view class="group-title text-[14px] text-main py-[5px] sticky top-0 bg-white z-[999] sticky-title">
{{ group.name }}
</view>
<view v-for="(item, idx) in group.data" :key="idx" class="list-item text-main text-[14px] py-[10px]" @click="handleSelect(item)">
{{ `${item.name} (+${item.code})` }}
</view>
</view>
</template>
<view v-else v-for="(item, idx) in filterList" :key="idx" class="list-item text-main text-[14px] py-[10px]" @click="handleSelect(item)">
{{ `${item.name} (+${item.code})` }}
</view>
</scroll-view>
<!-- 右侧索引 -->
<view class="index-bar absolute top-0 right-0 py-[10px] gap-[10px] z-[9999]">
<view
v-for="(group, index) in list"
:key="index"
class="index-item text-center text-[12px] "
:class="currentIntoId === group.groupKey ? 'bg-[#e61e30] rounded-full text-white' : 'text-[#e61e30]'"
@click="handleIndexclick(group.groupKey)"
>
{{ group.groupKey }}
</view>
</view>
</view>
</view>
</up-popup>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue';
import { onLoad, onReady } from '@dcloudio/uni-app';
import { useSystemInfo } from '@/hooks/useSystemInfo.js';
import { useI18n } from 'vue-i18n';
import { isEmpty } from '@/common/common.js';
import { apiWholeAreaCode } from '@/api/common.js';
const { t, locale } = useI18n();
const { computedDomHeight } = useSystemInfo();
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
// 获取区域码数据
const getAreaCodeList = async () => {
apiWholeAreaCode({ type: '' }).then((res) => {
if (res.success) {
list.value = res.data.codeGroups;
nextTick(() => {
calculateGroupPositions();
});
}
});
};
const isShow = ref(false);
watch(
() => props.show,
(newVal) => {
isShow.value = newVal;
if (newVal) getAreaCodeList();
},
{
immediate: true,
}
);
const emits = defineEmits(['close', 'select']);
const close = () => emits('close');
const keyword = ref('');
const filterList = ref([]);
const handleKeywordChange = (e) => {
if (!e) return (filterList.value = []);
const result = [];
for (let group of list.value) {
for (let item of group.data) {
const target = item.search?.replace(/\s+/g, '');
if (target.includes(e)) {
result.push(item);
}
}
}
filterList.value = uniqueByCode(result, 'code');
};
// 数组对象去重
const uniqueByCode = (arr, field) => {
const map = new Map(); // 用于存储已经出现过的 field
return arr.filter((item) => {
if (!map.has(item[field])) {
map.set(item[field], true); // 将 field 添加到 Map 中
return true; // 保留当前对象
}
return false; // 过滤掉重复对象
});
};
const handleSelect = (e) => {
emits('select', e);
};
// 数据
const list = ref([]);
const scrollIntoId = ref(''); // 当前滚动到的锚点ID
const currentIntoId = ref(''); // 当前的索引
const groupPositions = ref([]); // 存储每个组的顶部位置
// 点击索引跳转
const handleIndexclick = (title) => {
keyword.value = '';
scrollIntoId.value = title;
currentIntoId.value = title;
};
// 处理滚动事件
const handleScroll = (event) => {
const scrollTop = event.detail.scrollTop;
// 根据滚动位置判断当前吸顶的标题
for (let i = groupPositions.value.length - 1; i >= 0; i--) {
if (scrollTop > groupPositions.value[i].top) {
currentIntoId.value = groupPositions.value[i].title;
break;
}
}
};
// 计算每个组的顶部位置
const calculateGroupPositions = async () => {
let heightList = [];
for (let i = 0; i < list.value.length; i++) {
const group = list.value[i];
const data = await computedDomHeight(`#${group.groupKey}`);
if (data) {
heightList[i] = data.height || 0;
groupPositions.value.push({
title: group.groupKey,
top: i > 0 ? heightList.slice(0, i).reduce((pre, cur) => cur + pre, 0) : 0,
});
}
}
};
</script>
<style scoped lang="scss"></style>
+91
View File
@@ -0,0 +1,91 @@
<script setup>
import { ref, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
const emit = defineEmits(['confirm', 'close']);
const props = defineProps({
title: {
// 選擇器標題
type: String,
default: () => {
return '';
},
},
show: {
// 展示狀態
type: Boolean,
default: () => {
return false;
},
},
columns: {
// 數組列表
type: Array,
default: () => {
return [];
},
},
keyName: {
type: String,
default: () => {
return 'name';
},
},
});
const showPicker = ref(false); // 是否显示弹窗
const columnList = ref([]); // 列表数据
const onConfirm = (e) => {
emit('confirm', e.value);
};
const onCancel = () => {
emit('close');
};
watch(
() => props.show,
(newVal) => {
showPicker.value = newVal;
},
{
immediate: true,
deep: true,
}
);
watch(
() => props.columns,
(newVal) => {
// console.log(newVal);
columnList.value.push(
newVal.map((item) => {
return {
...item,
};
})
);
// console.log(columnList.value);
},
{
// immediate: true,
deep: true,
}
);
</script>
<template>
<!-- <up-popup :show="showPicker" :round="10" mode="bottom" :closeOnClickOverlay="true" @close="onCancel"> -->
<up-picker
:show="showPicker"
:title="props.title"
:confirmText="t('confirm')"
:cancelText="t('cancel')"
confirmColor="#274C40"
:columns="columnList"
:keyName="keyName"
:closeOnClickOverlay="true"
@confirm="onConfirm"
@cancel="onCancel"
@close="onCancel"
></up-picker>
<!-- </up-popup> -->
</template>
<style lang="scss" scoped></style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<view class="relative">
<!-- 输入框触发 -->
<input
type="text"
readonly
:value="selectedDate ? selectedDate.format('YYYY-MM-DD') : '选择日期'"
@click="showCalendar = !showCalendar"
class="w-full px-4 py-2 border-2 border-[#9C032D] rounded-lg cursor-pointer bg-white"
/>
<!-- 日历容器 -->
<view
v-if="showCalendar"
class="fixed bottom-0 left-1/2 transform -translate-x-1/2 mb-4 w-[320px] bg-[#f8d7da] border-2 border-[#9C032D] rounded-lg p-4 z-50 shadow-xl"
>
<!-- 头部 -->
<view class="flex justify-between items-center mb-4">
<button @click="changeMonth(-1)" class="text-[#9C032D] hover:text-[#80022a] transition-colors">◀</button>
<view class="font-bold text-[#9C032D]">
{{ currentDate.format('YYYY年MM月') }}
</view>
<button @click="changeMonth(1)" class="text-[#9C032D] hover:text-[#80022a] transition-colors">▶</button>
</view>
<!-- 星期 -->
<view class="grid grid-cols-7 gap-1 mb-2">
<view v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day" class="text-center text-sm text-[#9C032D] font-medium">
{{ day }}
</view>
</view>
<!-- 日期格子 -->
<view class="grid grid-cols-7 gap-1">
<view
v-for="(date, index) in calendarDays"
:key="index"
@click="selectDate(date)"
:class="[
'text-center p-1 rounded cursor-pointer transition-colors',
date.month() !== currentDate.month() ? 'text-gray-400' : 'text-[#9C032D]',
date.isSame(selectedDate, 'day') ? 'bg-[#9C032D] text-white' : 'hover:bg-[#f5b5ba]',
]"
>
{{ date.date() }}
</view>
</view>
<!-- 确认按钮 -->
<button @click="confirmDate" class="w-full mt-4 py-2 bg-[#9C032D] text-white rounded-lg hover:bg-[#80022a] transition-colors">确认</button>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import dayjs from 'dayjs';
const props = defineProps({
modelValue: String, // 接收外部v-model
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const showCalendar = ref(false);
const currentDate = ref(dayjs());
const selectedDate = ref(props.modelValue ? dayjs(props.modelValue) : null);
// 生成日历日期数组
const calendarDays = computed(() => {
const start = currentDate.value.startOf('month').startOf('week');
const end = currentDate.value.endOf('month').endOf('week');
const days = [];
let day = start;
while (day <= end) {
days.push(day);
day = day.add(1, 'day');
}
return days;
});
// 切换月份
const changeMonth = (delta) => {
currentDate.value = currentDate.value.add(delta, 'month');
};
// 选择日期
const selectDate = (date) => {
selectedDate.value = date;
};
// 确认选择
const confirmDate = () => {
showCalendar.value = false;
emit('update:modelValue', selectedDate.value.format('YYYY-MM-DD'));
emit('confirm', selectedDate.value.format('YYYY-MM-DD'));
};
</script>
+214
View File
@@ -0,0 +1,214 @@
<script setup>
/**
* @desc 通用布局组件
*/
import { onMounted, ref } from 'vue';
// import LoginPopup from './login-popup.vue';
import { getSystemSafeArea, getSafeTopHeight, getSystemInfo, isEmpty } from '@/common/common.js';
import { useUserStore } from '@/store/user';
import { useSystemInfo } from '@/hooks/useSystemInfo.js';
const { navHeight } = useSystemInfo();
const props = defineProps({
showBgImage: {
// 是否展示导航栏
type: Boolean,
default: () => {
return false;
},
},
showNav: {
// 是否展示导航栏
type: Boolean,
default: () => {
return true;
},
},
navTitle: {
//标题
type: String,
default: () => {
return '';
},
},
// navPlaceholder: {
// //是否生成一个等高元素,防止塌陷
// type: Boolean,
// default: () => {
// return false;
// },
// },
navShowReturn: {
//是否显示返回图标
type: Boolean,
default: () => {
return false;
},
},
navShowNavBar: {
//是否显示更多图标
type: Boolean,
default: () => {
return false;
},
},
navColor: {
//标题背景颜色
type: String,
default: () => {
return '#fff';
},
},
bgColor: {
//标题字体、icon颜色
type: String,
default: () => {
return '#fbfbfd';
},
},
titleColor: {
//标题字体、icon颜色
type: String,
default: () => {
return '#1D1D1F';
},
},
titleText: {
// 顶部标题文字
type: String,
default: () => {
return '';
},
},
// showLogin: {
// // 唤起登录
// type: Boolean,
// default: () => {
// return false;
// },
// },
});
const emit = defineEmits(['getContentHeight', 'loginStatus']);
const safeAreaInsetTop = ref(false); //是否开启顶部安全区适配
const viewHeight = ref(0); //可用高度
const pageHeight = ref(0); //页面高度
const pageWidth = ref(getSystemInfo().windowWidth);
const hasTabbar = ref(false);
const user = useUserStore();
const navPlaceholder = ref(false);
// #ifndef MP-WEIXIN
navPlaceholder.value = false;
// #endif
// #ifdef MP-WEIXIN
navPlaceholder.value = true;
// #endif
/**
* @desc 返回上一页
*/
const returnBack = () => {
uni.navigateBack(1);
};
/**
* @desc 返回内容部分的高度
*/
const contentHeight = () => {
emit('getContentHeight', {
viewHeight: viewHeight.value,
pageHeight: pageHeight.value,
});
};
const showSideNav = ref(false);
// 关闭/打开侧边弹出层
const operaSideNav = () => {
showSideNav.value = !showSideNav.value;
};
// 关闭登录弹窗
// const onCloseLogin = () => {
// const status = isEmpty(user.userToken); // true 为空,false为已登录
// emit('loginStatus', status);
// };
onMounted(() => {
// #ifndef MP-ALIPAY
safeAreaInsetTop.value = true;
// #endif
uni.showTabBar({
success() {
hasTabbar.value = true;
},
fail() {
hasTabbar.value = false;
},
complete() {
pageHeight.value = getSystemSafeArea();
if (props.showNav) {
viewHeight.value = pageHeight.value - getSafeTopHeight();
} else {
viewHeight.value = pageHeight.value;
}
contentHeight();
},
});
});
</script>
<template>
<view class="absolute left-[50%] right-0 top-0 translate-x-[-50%]" :style="{ height: `${pageHeight}px`, width: `${pageWidth}px` }">
<up-navbar
:title="props?.navTitle"
:safeAreaInsetTop="safeAreaInsetTop"
:placeholder="navPlaceholder"
:bgColor="props.navColor"
:titleStyle="{ color: props.titleColor }"
:height="navPlaceholder ? '44px' : `${getSafeTopHeight()}px`"
class="!flex-1"
v-if="props.showNav"
>
<template #left>
<view class="flex flex-row items-center gap-[10px]">
<up-icon name="list-dot" :color="props.titleColor" size="26" @click="operaSideNav" v-if="props.navShowNavBar"></up-icon>
<!-- #ifndef MP-BAIDU -->
<up-icon name="arrow-left" :color="props.titleColor" size="24" @click="returnBack" v-if="props.navShowReturn"></up-icon>
<!-- #endif -->
</view>
</template>
</up-navbar>
<view
class="relative left-0 right-0 top-0"
:style="{
height: `${viewHeight}px`,
background: props.bgColor,
top: navPlaceholder ? 0 : `${getSafeTopHeight()}px`,
}"
>
<view v-if="showBgImage" class="w-full relative">
<view
class="w-full h-[calc(200px+var(--status-bar-height))] bg-cover bg-center bg-no-repeat"
style="background-image: url('/static/test/top2.png')"
></view>
<text class="w-full text-[#fff] font-bold text-[20px] absolute left-0 right-0 bottom-[40px] text-center px-[40px]">
{{ props.titleText }}
</text>
</view>
<view
class="absolute left-0 right-0 w-full overflow-y-auto"
:style="{
top: showBgImage ? 'calc(200px + var(--status-bar-height))' : props.showNav ? 0 : `${navHeight}px`,
height: showBgImage ? `calc(100% - 200px - var(--status-bar-height))` : '100%',
}"
>
<slot name="content" :row="{ viewHeight, pageHeight }"> </slot>
</view>
<!-- 登录 -->
<!-- <LoginPopup :show="props.showLogin" @close="onCloseLogin"></LoginPopup> -->
</view>
</view>
</template>
<style lang="scss"></style>
+44
View File
@@ -0,0 +1,44 @@
<template>
<up-popup :show="showPicker" :closeable="true" round="10" mode="bottom" @close="onClose">
<view class="login">
<view class="logo mb-[10rpx] mt-[50rpx] flex justify-center text-center">
<image class="h-[140rpx] rounded-full" src="/static/common/logo.png" mode="heightFix"></image>
</view>
<Login @close="onClose"></Login>
</view>
</up-popup>
</template>
<script setup>
import { ref, watch } from 'vue';
import Login from './login.vue';
const props = defineProps({
show: {
// 展示狀態
type: Boolean,
default: () => {
return false;
},
},
});
const emit = defineEmits(['close']);
const onClose = () => {
emit('close');
};
const showPicker = ref(false);
watch(
() => props.show,
(newVal) => {
showPicker.value = newVal;
},
{
immediate: true,
deep: true,
}
);
</script>
<style lang="scss" scoped></style>
+855
View File
@@ -0,0 +1,855 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { areaCodeOptions, checkTelPhone, checkEmail, createV, showLoadingToast, closeToast, showToast, isEmpty } from '@/common/common.js';
import { getLoginCode } from '@/common/login.js';
import { login, register, sendCode, policyServer, loginLRInit } from '@/api/user.js';
import { useUserStore } from '@/store/user';
import { useI18n } from 'vue-i18n';
import CommonPicker from './common-picker.vue';
const { t, locale } = useI18n();
const userStore = useUserStore();
const isLogin = ref(true); // 是否登录,默认登录,为false表示注册
import { uniLogin } from '@/common/login';
const isMobile = ref(true); //是否手机号登录
const isPassword = ref(false); //是否密码登录
const isForgetPassword = ref(false); //是否忘记密码
// const isDisable = ref(true); //是否登录注册按钮是否禁用,即是否通过校验
const mobile = ref(''); // 手机号
const areaCode = ref(''); // 地区码
const code = ref(''); // 验证码
const password = ref(''); // 密码
const passwordConfirm = ref(''); // 确认密码
const email = ref(''); // 邮箱
const emit = defineEmits(['close']);
const wxJsCode = ref({}); //wx授权码code
const codeType = ref(''); // 区分code信息类型:=mpLogin(小程序中手机号+验证码登录);=mpMobile(小程序中授权获取手机号登录)
//同意条款相关
const checked = ref(false);
const onChange = (e) => {
checked.value = !checked.value;
};
//地区码弹框相关
const columns = ref([]);
const showCommonPicker = ref(false);
const onConfirmCommonPicker = (data) => {
areaCode.value = data[0].value;
showCommonPicker.value = false;
};
const onCloseCommonPicker = () => {
showCommonPicker.value = false;
};
//导航手机号点击功能
const onMobile = () => {
isMobile.value = true;
password.value = '';
passwordConfirm.value = '';
// isDisable.value = true;
// console.log(isMobile.value);
};
//导航邮箱点击功能
const onEmail = () => {
isMobile.value = false;
isPassword.value = true;
password.value = '';
passwordConfirm.value = '';
// isDisable.value = true;
};
//去登录
const toLogin = () => {
isLogin.value = true;
isMobile.value = true;
isPassword.value = true;
mobile.value = '';
areaCode.value = '852';
code.value = '';
password.value = '';
passwordConfirm.value = '';
email.value = '';
clearInterval(timerId2.value);
};
//去注册
const toRegister = () => {
isLogin.value = false;
isMobile.value = true;
isPassword.value = true;
mobile.value = '';
areaCode.value = '852';
code.value = '';
password.value = '';
passwordConfirm.value = '';
email.value = '';
clearInterval(timerId1.value);
};
/**
* @desc 登录
*/
const onLogin = () => {
if (!checked.value) {
uni.showToast({
title: t('policyTips'),
icon: 'none',
duration: 2000,
});
return;
}
//登录参数
let params = {};
//小程序免验证登录,codeType.value = mpMobile时,表示本次登录为免验证登录,noVerify值为1
if (codeType.value == 'mpMobile') {
params.codeValue = JSON.stringify(wxJsCode.value); //微信端需要传递登录授权code
params.noVerify = 1; // 免验证登录必传, codeType.value = mpMobile 时,为1表示本次登录为免验证登录
} else {
//手机号+验证码登录
params.codeType = userStore.platform == 'h5' ? '' : codeType.value; // 小程序有效,仅在短信验证码登录时有值 mpLogin
params.loginType = userStore.platform == 'h5' ? 'mobile' : ''; // h5环境下短信验证码登录必须要
params.areaCode = isMobile.value ? areaCode.value : ''; // 区号
params.mobile = isMobile.value ? mobile.value : ''; // 手机号
params.verifyCode = code.value; // 验证码
// #ifdef MP-WEIXIN || MP-ALIPAY
params.codeValue = JSON.stringify(wxJsCode.value); //微信端需要传递登录授权code
// #endif
//登录前的验证
if (!LoginCheck()) {
return;
}
}
console.log(params);
try {
uni.showLoading({
mask: true,
});
login(params).then((res) => {
console.log(res);
uni.hideLoading();
if (res.success) {
userStore.userToken = res.data.token;
userStore.isLogin = res.data.isLogin;
userStore.setProfile({
...res.data.userData,
});
emit('close');
mobile.value = '';
password.value = '';
code.value = '';
email.value = '';
} else {
//重新获取code
// #ifdef MP-WEIXIN || MP-ALIPAY
wxGetLoginCode();
// #endif
uni.showToast({
title: res.message,
icon: 'none',
});
}
});
} catch (error) {
//重新获取code
// #ifdef MP-WEIXIN || MP-ALIPAY
wxGetLoginCode();
// #endif
uni.showToast({
title: error.message,
icon: 'none',
});
} finally {
}
};
//登录校验
const LoginCheck = (val) => {
if (isMobile.value) {
//手机号登录
if (!checkTelPhone(areaCode.value, mobile.value)) {
uni.showToast({
title: t('personal.mobileTips'),
icon: 'none',
});
return false;
}
if (isPassword.value) {
//密码登录
if (password.value == '' || password.value == null) {
uni.showToast({
title: '请输入密码',
icon: 'none',
});
return false;
}
} else {
//验证码登录
if (code.value == '' || code.value == null) {
uni.showToast({
title: t('personal.validatePlaceholder'),
icon: 'none',
});
return false;
}
}
} else {
//邮箱登录
if (!checkEmail(email.value)) {
uni.showToast({
title: '请输入正确的电子邮箱',
icon: 'none',
});
return false;
}
if (password.value == '' || password.value == null) {
uni.showToast({
title: '请输入密码',
icon: 'none',
});
return false;
}
}
return true;
};
//注册
const onRegister = () => {
if (!checked.value) {
uni.showToast({
title: t('policyTips'),
icon: 'none',
duration: 2000,
});
return;
}
let params = {
regType: isMobile.value ? '1' : '2', //1手機號 2郵箱
areaCode: isMobile.value ? areaCode.value : '',
mobile: isMobile.value ? mobile.value : '',
password: isPassword.value ? password.value : '',
passwordConfirm: isPassword.value ? passwordConfirm.value : '',
code: isMobile.value ? code.value : '',
email: !isMobile.value ? email.value : '',
// #ifdef MP-WEIXIN || MP-ALIPAY
wxJsCode: wxJsCode.value, //微信端需要传递登录授权code
// #endif
};
console.log(params);
if (!RegisterCheck()) {
return;
}
try {
uni.showLoading({
mask: true,
});
//注册api
register(params).then((res) => {
uni.hideLoading();
if (res.success) {
userStore.userToken = res.data.token;
userStore.setProfile({
...res.data.user,
});
emit('close');
mobile.value = '';
password.value = '';
passwordConfirm.value = '';
code.value = '';
email.value = '';
} else {
//重新获取code
// #ifdef MP-WEIXIN || MP-ALIPAY
wxGetLoginCode();
// #endif
uni.showToast({
title: res.message,
icon: 'none',
});
}
});
} catch (error) {
//重新获取code
// #ifdef MP-WEIXIN || MP-ALIPAY
wxGetLoginCode();
// #endif
uni.showToast({
title: error.message,
icon: 'none',
});
} finally {
}
};
//注册校验
const RegisterCheck = (val) => {
if (isMobile.value) {
//手机号注册
if (!checkTelPhone(areaCode.value, mobile.value)) {
uni.showToast({
title: '请输入正确的地区号和手机号',
icon: 'none',
});
return false;
}
if (code.value == '' || code.value == null) {
uni.showToast({
title: '请输入验证码',
icon: 'none',
});
return false;
}
} else {
//邮箱注册
if (!checkEmail(email.value)) {
uni.showToast({
title: '请输入正确的电子邮箱',
icon: 'none',
});
return false;
}
}
if (password.value == '' || password.value == null) {
uni.showToast({
title: '请输入密码',
icon: 'none',
});
return false;
} else {
if (password.value?.length < 6 || password.value?.length > 20) {
uni.showToast({
title: '密碼長度應為6-20位',
icon: 'none',
});
return false;
}
}
if (passwordConfirm.value == '' || passwordConfirm.value == null) {
uni.showToast({
title: '請再次輸入密碼',
icon: 'none',
});
return false;
} else {
if (password.value != passwordConfirm.value) {
uni.showToast({
title: '兩次密碼不一樣',
icon: 'none',
});
return false;
}
}
return true;
};
const dataV = ref(''); //加密数据
const isCanWxLogin = ref(false); // 允许国内公司快速获取手机号登录
/**
* 初始化,获取data.v
*/
const initMobile = async () => {
try {
await loginLRInit().then((res) => {
if (res.success) {
areaCode.value = res.data.areaCodeList[0].value;
isCanWxLogin.value = res.data.isAllowMpMobile; // 允许国内公司快速获取手机号登录
res.data.areaCodeList.forEach((item, index) => {
columns.value[index] = {
value: item.value,
name: item.label,
};
});
dataV.value = res.data.v;
console.log(dataV.value);
} else {
uni.showToast({
title: res.message,
icon: 'none',
});
}
});
} catch (error) {
uni.showToast({
title: error.message,
icon: 'none',
});
}
};
const timerId1 = ref(null); // 定时器1(登录)
const timerId2 = ref(null); // 定时器2(注册)
const countDown1 = ref(60); // 倒计时1(登录)
const allowSendCode1 = ref(true); // 允许发送验证码1(登录)
const countDown2 = ref(60); // 倒计时2(注册)
const allowSendCode2 = ref(true); // 允许发送验证码2(注册)
//获取验证码
const getValifyCode = () => {
//手机号注册
if (!checkTelPhone(areaCode.value, mobile.value)) {
uni.showToast({
title: t('personal.mobileTips'),
icon: 'none',
});
return false;
}
let account = areaCode.value + ',' + mobile.value;
//加密data.v
let postV;
if (isLogin.value) {
postV = createV(dataV.value, account);
} else {
postV = createV(dataV.value, account);
}
let params = {
// account: isMobile.value ? account : '', //1手機號 2郵箱
// type: isMobile.value ? '1' : '2', //類型:1 手機號 2 郵箱
// purpose: !isLogin.value ? '1' : !isForgetPassword.value ? '2' : '3', //目的:1註冊 2登錄 3忘記密碼
areaCode: areaCode.value,
mobile: mobile.value,
v: postV, //加密数据
};
try {
// console.log(params);
uni.showLoading({
mask: true,
});
sendCode(params).then((res) => {
uni.hideLoading();
if (res.success) {
uni.showToast({
title: t('personal.resendSuccess'),
icon: 'none',
});
// #ifdef MP
codeType.value = 'mpLogin';
// #endif
if (isLogin.value) {
allowSendCode1.value = false;
timerId1.value = setInterval(() => {
countDown1.value--;
if (countDown1.value == 0) {
countDown1.value = 60;
allowSendCode1.value = true;
clearInterval(timerId1.value);
// console.log(timerId1.value);
}
}, 1000);
} else {
allowSendCode2.value = false;
timerId2.value = setInterval(() => {
countDown2.value--;
if (countDown2.value == 0) {
countDown2.value = 60;
allowSendCode2.value = true;
clearInterval(timerId2.value);
}
}, 1000);
}
} else {
uni.showToast({
title: res.message,
icon: 'none',
});
}
});
} catch (error) {
uni.showToast({
title: error.message,
icon: 'none',
});
}
};
//忘记密码
const toForgetPassword = () => {
uni.navigateTo({
url: '/pages/user/forgetPassword',
});
};
// 协议弹框相关
const noticeData = ref({}); //协议数据
const showAgreementPicker = ref(false);
const onShowAgreementPicker = () => {
showAgreementPicker.value = true;
};
const onCancelAgreementPicker = () => {
showAgreementPicker.value = false;
};
const getPolicyServer = async () => {
try {
await policyServer({
type: '',
}).then((res) => {
console.log(res);
if (res.success) {
noticeData.value = res.data?.content;
} else {
uni.showToast({
title: res.message,
icon: 'none',
});
}
});
} catch (error) {
uni.showToast({
title: error.message,
icon: 'none',
});
}
};
/**
* @desc 获取微信登录授权码
*/
const wxGetLoginCode = async () => {
await getLoginCode().then((res) => {
wxJsCode.value = res;
console.log(wxJsCode.value, '新获取的code');
});
};
/**
* @desc 免验证登录
*/
const quickLogin = () => {
getLoginCode().then((res) => {
// #ifdef MP
codeType.value = 'mpMobile';
// #endif
wxJsCode.value = res;
onLogin();
});
};
// 获取用户手机号码
const onGetphonenumber = (ev) => {
uni.$u.debounce(async () => {
if (ev.detail.errMsg.includes('fail')) return;
if (!checked.value) {
uni.showToast({
title: t('policyTips'),
icon: 'none',
duration: 2000,
});
return;
}
try {
uni.showLoading({
title: t('loading'),
mask: true,
});
const flag = await uniLogin(ev);
if (!flag) {
uni.showToast({
title: '登录失败',
icon: 'none',
});
} else {
emit('close');
console.log({
flag,
});
}
} catch (err) {
console.log({
err,
});
} finally {
uni.hideLoading();
}
}, 1000);
};
const handleChecked = () => {
if (!checked.value) {
uni.showToast({
title: t('policyTips'),
icon: 'none',
duration: 2000,
});
return;
}
};
onMounted(async () => {
await initMobile();
// await getPolicyServer();
// #ifdef MP-WEIXIN || MP-ALIPAY
await wxGetLoginCode();
// #endif
});
onUnmounted(() => {
clearInterval(timerId1.value);
clearInterval(timerId2.value);
});
</script>
<template>
<!-- <view class="login-type flex text-[34rpx] text-theme">
<view class="mobile-type flex-1 font-medium border-bottom" @tap="onMobile" :class="{ border-bottom-theme: isMobile }">{{ isLogin ? '手機號碼登入' : '手機號碼註冊' }}</view>
<view class="email-type flex-1 font-medium border-bottom" @tap="onEmail" :class="{ border-bottom-theme: !isMobile }">{{ isLogin ? '電郵登入' : '電郵註冊' }}</view>
</view> -->
<!-- 微信快捷登录 -->
<view v-if="isLogin && isCanWxLogin">
<view class="login-box">
<button
v-if="!checked"
class="up-reset-button bg-theme rounded-full mx-[30rpx] my-[40rpx] flex h-[92rpx] items-center justify-center text-[34rpx] font-medium text-white"
@click="handleChecked"
>
{{ t('phoneQuickLogin') }}
</button>
<button
v-else
class="up-reset-button bg-theme rounded-full mx-[30rpx] my-[40rpx] flex h-[92rpx] items-center justify-center text-[34rpx] font-medium text-white"
open-type="getPhoneNumber"
@getphonenumber="onGetphonenumber"
>
{{ t('phoneQuickLogin') }}
</button>
</view>
</view>
<!-- 登录 -->
<view v-else-if="isLogin">
<view class="mobile" v-if="isMobile">
<view class="label text-[26rpx]">{{ t('personal.mobile') }}</view>
<view class="mobile-text flex items-center border-round-theme">
<view class="areaCode flex items-center">
<view class="text-[30rpx]" @tap="showCommonPicker = true">+{{ areaCode }}</view>
<view class="down-icon">
<up-icon name="arrow-down-fill" size="8" color></up-icon>
</view>
</view>
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.mobilePlaceholder')" v-model="mobile" type="number" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.mobilePlaceholder')" v-model="mobile" type="number" border="none" />
<!-- #endif -->
</view>
</view>
<view class="email" v-else>
<view class="label text-[26rpx]">{{ t('personal.email') }}</view>
<view class="email-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.emailPlaceholder')" v-model="email" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.emailPlaceholder')" v-model="email" border="none" />
<!-- #endif -->
</view>
</view>
<view class="password" v-if="isPassword">
<view class="label text-[26rpx]">{{ t('personal.password') }}</view>
<view class="password-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.passwordPlaceholder')" v-model="password" type="password" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.passwordPlaceholder')" v-model="password" type="password" border="none" />
<!-- #endif -->
</view>
</view>
<view class="code" v-else>
<view class="label text-[26rpx]">{{ t('personal.validateCode') }}</view>
<view class="code-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.validatePlaceholder')" v-model="code" type="number" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.validatePlaceholder')" v-model="code" type="number" border="none" />
<!-- #endif -->
<view v-if="allowSendCode1" class="whitespace-nowrap text-[28rpx] font-medium text-theme" @tap="getValifyCode">{{
t('personal.getValidateCode')
}}</view>
<view v-else class="whitespace-nowrap text-[28rpx] font-medium text-[rgb(131,131,131)]"> {{ countDown1 }}s{{ t('personal.resend') }} </view>
</view>
</view>
<!-- 免验证登录 -->
<!-- #ifdef MP -->
<view class="flex flex-row items-center justify-center flex-1" @click="quickLogin">
<text class="text-[15px]">{{ t('quickLogin') }}</text>
</view>
<!-- #endif -->
<!-- <view class="text-right text-[26rpx] mx-[30rpx] text-gray-600" @tap="toForgetPassword">忘記密碼?</view> -->
<view class="bg-theme rounded-full mx-[30rpx] my-[40rpx] flex h-[92rpx] items-center justify-center text-[34rpx] font-medium text-white" @tap="onLogin">{{
t('login')
}}</view>
<!-- <view class="flex mx-[30rpx] items-center justify-between text-[28rpx]">
<view>沒有賬號?點挈 <text class="text-theme" @tap="toRegister">註冊</text></view>
<view v-if="isMobile" class="text-theme" @tap="isPassword = !isPassword">{{ !isPassword ? '密碼登入' : '验证码登入' }}</view>
</view> -->
</view>
<!-- 注册 -->
<view v-else>
<view class="mobile" v-if="isMobile">
<view class="label text-[26rpx]">{{ t('personal.mobile') }}</view>
<view class="mobile-text flex items-center border-round-theme">
<view class="areaCode flex items-center">
<view class="text-[30rpx]" @tap="showCommonPicker = true">+{{ areaCode }}</view>
<view class="down-icon">
<up-icon name="arrow-down-fill" size="8" color></up-icon>
</view>
</view>
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.mobilePlaceholder')" v-model="mobile" type="number" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.mobilePlaceholder')" v-model="mobile" type="number" border="none" />
<!-- #endif -->
</view>
</view>
<view class="email" v-else>
<view class="label text-[26rpx]">{{ t('personal.email') }}</view>
<view class="email-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.emailPlaceholder')" v-model="email" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.emailPlaceholder')" v-model="email" border="none" />
<!-- #endif -->
</view>
</view>
<view class="code" v-if="isMobile">
<view class="label text-[26rpx]">{{ t('personal.validateCode') }}</view>
<view class="code-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input placeholder="t('personal.validatePlaceholder')" v-model="code" type="number" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" placeholder="t('personal.validatePlaceholder')" v-model="code" type="number" border="none" />
<!-- #endif -->
<view v-if="allowSendCode2" class="whitespace-nowrap text-[28rpx] font-medium text-theme" @tap="getValifyCode">{{
t('personal.getValidateCode')
}}</view>
<view v-else class="whitespace-nowrap text-[28rpx] font-medium text-[rgb(131,131,131)]">
{{ `${countDown2}s ${t('personal.resend')}` }}
</view>
</view>
</view>
<view class="password">
<view class="label text-[26rpx]">{{ t('personal.password') }}</view>
<view class="password-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.setPassword')" v-model="password" type="password" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.setPassword')" v-model="password" type="password" border="none" />
<!-- #endif -->
</view>
</view>
<view class="password">
<view class="label text-[26rpx]">{{ t('personal.confirmPassword') }}</view>
<view class="password-text flex items-center border-round-theme">
<!-- #ifndef MP-ALIPAY -->
<up-input :placeholder="t('personal.passwordAgain')" v-model="passwordConfirm" type="password" border="none"></up-input>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<input class="flex-1" :placeholder="t('personal.passwordAgain')" v-model="passwordConfirm" type="password" border="none" />
<!-- #endif -->
</view>
</view>
<!-- :class="{ disable: isDisable }" -->
<view
class="bg-theme rounded-full mx-[30rpx] mt-[15rpx] my-[40rpx] flex h-[92rpx] items-center justify-center text-[34rpx] font-medium text-white"
@tap="onRegister"
>{{ t('regLogin') }}</view
>
<view class="flex mx-[30rpx] mb-[40rpx] items-center justify-between text-[28rpx]">
<view
>{{ t('hasAccount') }} <text class="text-theme" @tap="toLogin">{{ t('login') }}</text></view
>
</view>
</view>
<view class="text-center my-[40rpx] text-[28rpx]">
<checkbox-group @change="onChange">
<checkbox class="checkbox" :checked="checked" color="#00A88E"> </checkbox>
{{ t('policyNote') }} <text class="text-theme font-medium" @tap="onShowAgreementPicker">{{ t('policy') }}</text>
</checkbox-group>
</view>
<!-- 地区号 -->
<CommonPicker
:title="t('personal.selectAreaCode')"
:show="showCommonPicker"
:columns="columns"
@confirm="onConfirmCommonPicker"
@close="onCloseCommonPicker"
></CommonPicker>
<!-- 用户协议弹框 -->
<up-popup :show="showAgreementPicker" :closeable="true" round="10" mode="bottom" @close="onCancelAgreementPicker">
<view class="h-[70vh] flex flex-col px-[30rpx] pb-[30rpx] text-[28rpx]">
<view class="py-[30rpx] text-center text-[36rpx]">
{{ t('policy') }}
</view>
<view class="flex-1 overflow-y-scroll">
<up-parse :content="noticeData"></up-parse>
</view>
</view>
</up-popup>
</template>
<style lang="scss" scoped>
.border-bottom-theme .login-type {
height: 88rpx;
margin: 30rpx;
.mobile-type,
.email-type {
text-align: center;
line-height: 88rpx;
}
}
.mobile,
.email,
.password,
.code {
margin: 30rpx;
.label {
font-size: 26rpx;
padding-left: 40rpx;
padding-bottom: 20rpx;
}
.mobile-text,
.email-text,
.password-text,
.code-text {
// margin: 0 15px;
height: 100rpx;
padding: 0 40rpx;
border-radius: 100rpx;
.van-field {
background-color: #ffffff !important;
padding: 0 10rpx !important;
}
.areaCode {
margin-right: 17rpx;
.down-icon {
width: 40rpx;
padding-left: 10rpx;
}
border-right: 2rpx solid #eee;
}
}
}
.disable {
opacity: 0.3;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup>
import {
getPredictTime
} from '@/pages/booking/api.js';
import {
onMounted,
ref,
reactive,
watch
} from 'vue';
import {
useI18n
} from 'vue-i18n';
import {
getNowDate
} from '@/common/common';
import {
isEmpty,
showToast
} from '../common/common';
const {
t,
locale
} = useI18n();
const props = defineProps({
show: {
type: Boolean,
default: false,
},
date: {
type: String,
default: getNowDate(),
},
});
const emit = defineEmits(['close', 'confirm']);
const close = () => {
emit('close');
};
const pckList = ref([]); // 套餐总数组
const title = ref('');
/**
* @desc 選擇套餐
* @param res 選中的套餐
*/
const choiceValue = ref('');
const choiceData = ref({});
const choice = (res) => {
if (!res.time.status) {
return showToast(res.time.tips);
}
choiceData.value = {
pckId: res.item.pckId,
pckName: res.item.pckName,
...res.time,
};
choiceValue.value = res.time.value;
};
const confirm = () => {
if (isEmpty(choiceData.value)) {
return showToast(t('booking.useTimePlaceholder'));
}
emit('confirm', choiceData.value);
close();
};
/**
* @desc 查询用餐时间
*/
const init = () => {
return new Promise((resolve, reject) => {
getPredictTime({
date: props.date,
}).then((res) => {
if (res.success) {
pckList.value = res.data?.pckList;
title.value = res.data?.title;
resolve()
} else {
showToast(res.message)
reject()
}
});
})
}
const timeFun = reactive({
init
});
defineExpose({
timeFun,
});
onMounted(() => {
init()
});
</script>
<template>
<up-popup :show="props.show" mode="bottom" round="20" :closeable="true" :safeAreaInsetBottom="true" @close="close">
<view class="flex flex-col gap-[20px] p-[20px] h-[70vh] relative">
<view class="flex h-[44px] items-center justify-center text-[16px] text-[#1D1D1F]">{{ title }}</view>
<view class="flex flex-col overflow-auto time-height gap-[20px]">
<view class="flex flex-col gap-[20px]" v-for="item in pckList" :key="item.pckId">
<view class="flex flex-col gap-[10px]">
<view class="text-main text-[14px] font-bold">{{ item.pckName }}</view>
<view class="gap-[20px] grid grid-cols-2">
<view class="flex items-center justify-center h-[35px] rounded-[5px] text-[14px]"
v-for="time in item.useTimeData" :key="time.value" :class="{
'!bg-theme text-white': choiceValue === time.value,
'bg-[#999]/[.5] text-white': !time.status,
'bg-white text-main border-round-theme': time.status,
}" @click="choice({ item, time })">
{{ time.label }}
</view>
</view>
</view>
</view>
</view>
<view class="my-[20px] flex absolute bottom-0 left-[20px] right-[20px]">
<up-button class="h-[40px] flex-1" :text="t('confirm')" shape="circle" color="#B5966E"
@click="confirm" />
</view>
</view>
</up-popup>
</template>
<style scoped lang="scss">
.time-height {
height: calc(100% - 40px - 44px - 40px); // 按钮高度+title高度+内边距上下共40px
}
</style>