first commit

This commit is contained in:
cg
2026-09-03 22:21:38 +08:00
commit 63ce526195
34 changed files with 2139 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<template>
<view>
<image class="logo" src="/static/logo.png"></image>
<text class="title">{{title}}</text>
</view>
</template>
<script setup lang="uts">
const title = ref('Hello')
</script>
<style>
.logo {
height: 100px;
width: 100px;
margin: 100px auto 25px auto;
}
.title {
font-size: 18px;
color: #8f8f94;
text-align: center;
}
</style>
+715
View File
@@ -0,0 +1,715 @@
<template>
<view class="page">
<!-- 顶部:头像 + 登录状态入口(左),右上角蓝牙/WiFi/信号/时间(与 activate-device 页一致) -->
<view class="header">
<view class="header-user" @click="onHeaderUserClick">
<image class="header-avatar" src="/static/images/img-virtual.png" mode="aspectFill"></image>
<text class="header-name">{{ isLoggedIn ? '已登录' : '未登录' }}</text>
</view>
<view class="header-spacer"></view>
<view class="header-status">
<image class="status-icon" src="/static/icons/status-bluetooth.png" mode="aspectFit"></image>
<image class="status-icon status-icon-gap" src="/static/icons/status-wifi.png" mode="aspectFit"></image>
<image class="status-icon status-icon-gap" src="/static/icons/status-cellular.png" mode="aspectFit"></image>
<text class="header-time">{{ clockText }}</text>
</view>
</view>
<view class="content">
<!-- 左大卡:宣传位(限定 5 张,自动轮巡 + 左右滑动浏览) -->
<view class="card main-card">
<swiper
class="card-swiper"
:autoplay="true"
:circular="true"
:interval="4000"
:duration="400"
:indicator-dots="true"
indicator-color="rgba(255, 255, 255, 0.4)"
indicator-active-color="#FFFFFF"
>
<swiper-item v-for="(item, index) in promoImages" :key="index" @click="onPromoClick(index)">
<image class="card-bg" :src="item" mode="aspectFill"></image>
</swiper-item>
</swiper>
</view>
<!-- 右列 -->
<view class="right-col">
<!-- 右上卡:推荐位(限定 3 张,自动轮巡 + 左右滑动浏览) -->
<view class="card top-card">
<swiper
class="card-swiper"
:autoplay="true"
:circular="true"
:interval="4000"
:duration="400"
:indicator-dots="true"
indicator-color="rgba(255, 255, 255, 0.4)"
indicator-active-color="#FFFFFF"
>
<swiper-item v-for="(item, index) in recoImages" :key="index" @click="onRecoClick(index)">
<image class="card-bg" :src="item" mode="aspectFill"></image>
</swiper-item>
</swiper>
</view>
<!-- 下排两卡 -->
<view class="bottom-row">
<view class="card func-card func-left" @click="onVirtualClick">
<image class="card-bg" src="/static/images/img-virtual.png" mode="aspectFill"></image>
<view class="func-badge">
<image class="func-icon" src="/static/icons/icon-virtual.png" mode="aspectFit"></image>
<text class="func-text">虚拟体验</text>
</view>
</view>
<view class="card func-card func-right" @click="showModal = true">
<image class="card-bg" src="/static/images/img-login.png" mode="aspectFill"></image>
<view class="func-badge">
<image class="func-icon" src="/static/icons/icon-login.png" mode="aspectFit"></image>
<text class="func-text">立即登陆</text>
</view>
</view>
</view>
</view>
</view>
<!-- ============== 登录弹窗 ============== -->
<view v-if="showModal" class="mask" @click="onMaskClick">
<view class="modal" @click.stop>
<!-- 关闭 X -->
<view class="modal-close" @click="showModal = false">
<view class="modal-close-line1"></view>
<view class="modal-close-line2"></view>
</view>
<!-- 左侧插画(新容器:蓝描边 + 图居中 aspectFit,不裁切) -->
<view class="modal-banner-wrap">
<image class="modal-banner" src="/static/images/login-banner.png" mode="aspectFit"></image>
</view>
<!-- 右侧表单 -->
<view class="modal-form">
<text class="modal-switch">账号密码登录</text>
<!-- 手机号 -->
<view class="field" :class="{ fieldFocus: focusType === 'phone' }">
<view class="field-icon">
<view class="icon-user-1"></view>
<view class="icon-user-2"></view>
</view>
<input
class="field-input"
v-model="phone"
type="number"
maxlength="11"
placeholder="请填写手机号"
placeholder-class="field-placeholder"
@focus="focusType = 'phone'"
@blur="focusType = ''"
/>
</view>
<!-- 验证码:必填,限制 4 位数字,点击唤起数字键盘 -->
<view class="field" :class="{ fieldFocus: focusType === 'code' }">
<view class="field-icon">
<view class="icon-sms-box"></view>
<view class="icon-sms-flap"></view>
</view>
<input
class="field-input"
v-model="code"
type="number"
maxlength="4"
placeholder="请填写验证码"
placeholder-class="field-placeholder"
@focus="focusType = 'code'"
@blur="focusType = ''"
/>
<text class="code-btn" :class="{ disabled: smsCountdown > 0 }" @click="onGetSms">
{{ smsBtnText }}
</text>
</view>
<!-- 提交按钮 -->
<view class="submit-btn" @click="onSubmitLogin">
<text class="submit-text">注册或登录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
// ====== 宣传位 / 推荐位轮播数据 ======
// 宣传位:限定 5 张(暂用现有素材循环占位,接入投放系统后替换为真实图片列表)
const promoImages = ref<string[]>([
'/static/images/bg-main.png',
'/static/images/bg-top-right.png',
'/static/images/img-virtual.png',
'/static/images/img-login.png',
'/static/images/login-banner.png'
])
// 推荐位:限定 3 张
const recoImages = ref<string[]>([
'/static/images/bg-top-right.png',
'/static/images/bg-main.png',
'/static/images/img-login.png'
])
// 查看投放内容(预留:后续接入真实投放详情/跳转链接)
function onPromoClick(index : number) : void {
uni.showToast({ title: '查看投放内容 ' + (index + 1).toString(), icon: 'none' })
}
function onRecoClick(index : number) : void {
uni.showToast({ title: '查看投放内容 ' + (index + 1).toString(), icon: 'none' })
}
// ====== 登录弹窗状态 ======
const showModal = ref<boolean>(false)
const isLoggedIn = ref<boolean>(false)
const phone = ref<string>('13513321454')
const code = ref<string>('1234')
const focusType = ref<string>('')
// 验证码 60s 倒计时
const smsCountdown = ref<number>(0)
// 上次成功获取验证码的时间戳(用于 5s/次 频次限制)
let lastGetTs : number = 0
// 倒计时定时器句柄(-1 表示无)
let _smsTimer : number = -1
// 顶部「未登录」入口:未登录时打开登录弹窗,已登录时提示
function onHeaderUserClick() : void {
if (isLoggedIn.value) {
uni.showToast({ title: '已登录', icon: 'none' })
return
}
showModal.value = true
}
// 关闭弹窗:点击遮罩空白处
function onMaskClick() : void {
showModal.value = false
}
// 虚拟体验:暂未开放
function onVirtualClick() : void {
uni.showToast({ title: '暂未开放', icon: 'none' })
}
// 校验手机号(必填,11 位有效数字,1 开头)
function isValidPhone(p : string) : boolean {
if (p.length != 11) return false
if (!p.startsWith('1')) return false
for (let i = 0; i < p.length; i++) {
if (p[i] < '0' || p[i] > '9') return false
}
return true
}
// ====== 验证码按钮文案:倒计时中显示「Ns」,倒计时结束显示「重新获取」,初始「获取验证码」 ======
const hasGotOnce = ref<boolean>(false)
const smsBtnText = computed<string>((): string => {
if (smsCountdown.value > 0) return smsCountdown.value.toString() + 's'
if (hasGotOnce.value) return '重新获取'
return '获取验证码'
})
// ====== 每日获取次数限制:手机号 / 10 次 / 天(本地按 手机号+日期 计数,接入 API 后由服务端校验) ======
const SMS_DAILY_LIMIT = 10
function smsCountKey() : string {
const d = new Date()
return 'sms_count_' + phone.value + '_' + d.getFullYear().toString() + '-' + (d.getMonth() + 1).toString() + '-' + d.getDate().toString()
}
function getTodaySmsCount() : number {
const v = uni.getStorageSync(smsCountKey())
if (v == null) return 0
const n = parseInt(v as string)
if (isNaN(n)) return 0
return n
}
function incrTodaySmsCount() : void {
uni.setStorageSync(smsCountKey(), (getTodaySmsCount() + 1).toString())
}
// 60s 倒计时循环
function startTick() : void {
_smsTimer = setTimeout(() : void => {
smsCountdown.value -= 1
if (smsCountdown.value > 0) {
startTick()
} else {
_smsTimer = -1
}
}, 1000)
}
// 点击【获取验证码】:验证手机号 → 频次/次数校验 → 发送 + 60s 倒计时
function onGetSms() : void {
if (smsCountdown.value > 0) return
// 验证失败:toast「请填写正确的手机号」
if (!isValidPhone(phone.value)) {
uni.showToast({ title: '请填写正确的手机号', icon: 'none' })
return
}
// 获取频次:5s/次,超过 toast「间隔时间过短」
const now = Date.now()
if (lastGetTs > 0 && now - lastGetTs < 5000) {
uni.showToast({ title: '间隔时间过短', icon: 'none' })
return
}
// 获取次数:手机号/10次/天,超出 toast「获取次数今日已达上限」
if (getTodaySmsCount() >= SMS_DAILY_LIMIT) {
uni.showToast({ title: '获取次数今日已达上限', icon: 'none' })
return
}
lastGetTs = now
incrTodaySmsCount()
hasGotOnce.value = true
// TODO: 接入真实短信发送 API
smsCountdown.value = 60
startTick()
}
// 点击【注册或登录】:验证编辑信息
function onSubmitLogin() : void {
if (!isValidPhone(phone.value)) {
uni.showToast({ title: '请填写正确的手机号', icon: 'none' })
return
}
// 验证码:必填,4 位数字
if (code.value.length != 4) {
uni.showToast({ title: '请填写正确的验证码', icon: 'none' })
return
}
// TODO: 接入真实注册登录 API + 设备绑定状态查询
isLoggedIn.value = true
showModal.value = false
// 验证成功 → 进入【添加设备】页面
uni.navigateTo({ url: '/pages/activate-device/activate-device' })
}
// ====== 顶部状态栏时间(与 activate-device 页一致:HH:mm 每秒刷新) ======
const clockText = ref<string>('00:00')
let _clockTimer : number = -1
function pad2(n : number) : string {
return n < 10 ? '0' + n.toString() : n.toString()
}
function refreshClock() : void {
const d = new Date()
clockText.value = pad2(d.getHours()) + ':' + pad2(d.getMinutes())
}
function startClock() : void {
refreshClock()
_clockTimer = setInterval(() : void => {
refreshClock()
}, 1000)
}
onLoad(() : void => {
startClock()
})
// 页面卸载时清理倒计时定时器 + 时钟定时器
onUnload(() : void => {
if (_smsTimer != -1) {
clearTimeout(_smsTimer)
_smsTimer = -1
}
if (_clockTimer != -1) {
clearInterval(_clockTimer)
_clockTimer = -1
}
})
</script>
<style>
/* ==================== 登录引导页基础 ==================== */
.page {
width: 750rpx;
height: 100%;
background-color: #040912;
flex-direction: column;
}
/* 顶部栏:设计稿 1920×104 → 750×40.63rpx;头像 60×60 在 (60,22),文字 36px 在 (144,28),右侧蓝牙/WiFi/信号/时间 */
.header {
width: 100%;
height: 40.63rpx; /* 104 → 40.63rpx */
flex-direction: row;
align-items: center;
padding-left: 23.44rpx; /* 60 → 23.44rpx */
padding-right: 23.44rpx; /* 右侧与 activate-device 页一致 */
}
.header-user {
flex-direction: row;
align-items: center;
}
/* 头像 60×60 → 23.44rpx 圆形 */
.header-avatar {
width: 23.44rpx;
height: 23.44rpx;
border-radius: 11.72rpx; /* 50% */
}
/* 「未登录」36px Regular 白;与头像间距 24 → 9.38rpx */
.header-name {
margin-left: 9.38rpx;
font-size: 14.06rpx; /* 36 → 14.06rpx */
color: #FFFFFF;
}
.header-spacer {
flex: 1;
}
.header-status {
flex-direction: row;
align-items: center;
}
/* 三个状态栏图标:均为 23.44rpx(60×60 Figma px),相邻间距 24 → 9.38rpx */
.status-icon {
width: 23.44rpx;
height: 23.44rpx;
}
.status-icon-gap {
margin-left: 9.38rpx;
}
/* 时间:与信号图标间距 24 → 9.38rpx;36px Regular 白;行高 14.06rpx 与图标对齐 */
.header-time {
margin-left: 9.38rpx;
font-size: 14.06rpx;
line-height: 14.06rpx;
color: #FFFFFF;
text-align: right;
}
.content {
flex: 1;
flex-direction: row;
padding-left: 23.44rpx; /* 60 → 23.44rpx */
padding-right: 23.44rpx;
padding-bottom: 23.44rpx;
padding-top: 14.06rpx; /* 设计稿卡片 y=140,header 高 104 → 间距 36 → 14.06rpx */
}
.card {
background-color: #D8D8D8;
overflow: hidden;
position: relative;
border-radius: 7.81rpx; /* 20 → 7.81rpx */
}
.card-bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
/* 轮播铺满卡片(宣传位 / 推荐位) */
.card-swiper {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.main-card {
width: 343.75rpx; /* 880 → 343.75rpx */
height: 100%;
flex-shrink: 0;
}
.right-col {
flex: 1;
margin-left: 10.94rpx; /* 28 → 10.94rpx */
flex-direction: column;
}
.top-card {
flex: 486;
width: 100%;
}
.bottom-row {
flex: 486;
margin-top: 10.94rpx;
flex-direction: row;
width: 100%;
}
.func-card {
flex: 1;
width: 0;
height: 100%;
align-items: center;
justify-content: center;
}
.func-right {
margin-left: 10.94rpx;
flex-shrink: 0;
}
/* 徽标 192×48 → 75×18.75rpx;圆角 20 → 7.81rpx */
.func-badge {
width: 75rpx;
height: 18.75rpx;
border-radius: 7.81rpx;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: #242738;
box-shadow: 0 0.78rpx 3.13rpx rgba(0, 0, 0, 0.4);
}
/* 图标 36×36 → 14.06rpx;文字 36 Heavy→14.06rpx;间距 13→5.08rpx */
.func-icon {
width: 14.06rpx;
height: 14.06rpx;
}
.func-text {
color: #FFFFFF;
font-weight: 900;
font-size: 14.06rpx;
margin-left: 5.08rpx;
}
/* ==================== 登录弹窗 ==================== */
/* 设计稿:1920 基准;遮罩 1920×1200 rgba(31,35,41,0.6) */
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(31, 35, 41, 0.6);
align-items: center;
justify-content: center;
z-index: 999;
}
/* 弹窗 1280×720 → 500 × 281.25rpx;背景 #030A16;圆角 20 → 7.81rpx;相对画板 320/240 居中 */
.modal {
width: 500rpx;
height: 281.25rpx;
background-color: #030A16;
border-radius: 7.81rpx;
flex-direction: row;
position: relative;
overflow: hidden;
box-shadow: 0 3.91rpx 15.63rpx rgba(0, 0, 0, 0.5);
}
/* 关闭 X 30×30 → 11.72rpx 方形;相对弹窗 1230/20 → 弹窗右上 50/7.81rpx */
.modal-close {
position: absolute;
top: 7.81rpx;
right: 19.53rpx; /* 设计稿 (1280-1230-30)=20 → 7.81rpx,稍放大一点更易点 */
width: 11.72rpx;
height: 11.72rpx;
justify-content: center;
align-items: center;
}
.modal-close-line1 {
position: absolute;
width: 11.72rpx;
height: 1rpx;
background-color: #FFFFFF;
transform: rotate(45deg);
}
.modal-close-line2 {
position: absolute;
width: 11.72rpx;
height: 1rpx;
background-color: #FFFFFF;
transform: rotate(-45deg);
}
/* 左侧插画容器:设计稿 Mask group 弹窗内 32/154 556×380 → 用比例换算 + 匹配图1效果
修正:
1. 加上蓝边(~2px 粗亮蓝描边,与 Figma 截图的蓝色一致)
2. 图片按 aspectFit 居中展示真实车身 PNG,不裁切
*/
.modal-banner-wrap {
position: absolute;
left: 12.5rpx;
top: 60.16rpx;
width: 217.19rpx;
height: 148.44rpx;
background-color: transparent;
align-items: center;
justify-content: center;
}
.modal-banner {
width: 80%;
height: 80%;
border-radius: 0;
}
/* 右侧表单:起点 620/162 570×60 → 占弹窗右,用比例 */
.modal-form {
position: absolute;
left: 242.19rpx; /* 620/1280*500 ≈ 242.19 */
top: 63.28rpx; /* 162/720*281.25 = 63.28 */
width: 222.66rpx; /* 570/1280*500 ≈ 222.66 */
flex-direction: column;
}
/* 切换文字:「账号密码登录」 20 → 7.81rpx;位置 y=162,距离顶部 0 → 相对弹窗 form 起点 162 即 top=0 对应 */
.modal-switch {
font-size: 7.81rpx; /* 20 → 7.81rpx */
color: #FFFFFF;
line-height: 14.06rpx; /* 36px lineHeight → 14.06rpx */
margin-bottom: 7.03rpx; /* 210 - 162 - 36 = 12 → 4.69rpx 微调 → 7.03rpx 使下间距合理 */
}
/* 输入框 570×60 → 222.66×23.44rpx;背景 rgba(36,39,56,0.8);圆角 4 → 1.56rpx;与上一个间距 38(308-270=38 → 210到308 98 → 38.28rpx 作为 form 内间距) */
.field {
margin-top: 14.84rpx; /* 输入框间距 (308-210-60=38) → 14.84rpx */
width: 222.66rpx;
height: 23.44rpx; /* 60 → 23.44rpx */
background-color: rgba(36, 39, 56, 0.8);
border-radius: 1.56rpx; /* 4 → 1.56rpx */
flex-direction: row;
align-items: center;
padding-left: 6.25rpx; /* 图标起点 16px相对输入框 → 6.25rpx */
padding-right: 3.91rpx; /* 小内边距 */
position: relative;
border-width: 0.78rpx;
border-style: solid;
border-color: transparent;
}
/* 设计稿「注册或登录」按钮有边框 rgba(217,217,217,0.8) 2px → 输入框聚焦时按同样风格高亮 */
.fieldFocus {
border-color: rgba(217, 217, 217, 0.8);
border-width: 0.78rpx;
}
/* 图标 24×24 相对输入框 left 16/top 18(评论框图标)、top 228-210=18 for 手机号 → 都用字段垂直居中 */
.field-icon {
width: 9.38rpx; /* 24 → 9.38rpx */
height: 9.38rpx;
margin-right: 2.34rpx; /* 占位 672-636-24 = 12 → 4.69rpx 一半值,实际用 2.34 使视觉紧凑 */
position: relative;
flex-shrink: 0;
}
/* 用户图标(Figma user备份2):大致由上半圆 + 下方梯形组成,用两个 view 拼出轮廓 */
.icon-user-1 {
position: absolute;
left: 1.56rpx;
top: 0;
width: 6.25rpx;
height: 6.25rpx;
border-width: 0.78rpx;
border-style: solid;
border-color: #D8D8D8;
border-radius: 3.13rpx; /* 50%效果 */
}
.icon-user-2 {
position: absolute;
left: 0;
bottom: 0;
width: 9.38rpx;
height: 4.69rpx;
border-top-left-radius: 4.69rpx;
border-top-right-radius: 4.69rpx;
border-width: 0.78rpx;
border-style: solid;
border-color: #D8D8D8;
border-bottom-width: 0;
}
/* 消息图标(Figma「评论」框 + 遮罩):信封轮廓 矩形 20×14 + 翻盖 17×5 */
.icon-sms-box {
position: absolute;
left: 0.78rpx;
top: 2.0rpx;
width: 7.81rpx; /* 20 → 7.81 */
height: 5.47rpx; /* 14 → 5.47 */
border-width: 0.78rpx;
border-style: solid;
border-color: #D8D8D8;
border-radius: 1.17rpx; /* 3 → 1.17 */
background-color: transparent;
}
.icon-sms-flap {
position: absolute;
left: 1.17rpx;
top: 2.81rpx;
width: 6.64rpx; /* 17 → 6.64 */
height: 1.95rpx; /* 5 → 1.95 */
border-top-width: 0.78rpx;
border-top-style: solid;
border-top-color: #D8D8D8;
}
.field-input {
flex: 1;
height: 23.44rpx;
color: #FFFFFF;
font-size: 9.38rpx; /* 24 → 9.38rpx */
line-height: 14.06rpx; /* 36 → 14.06rpx */
padding: 0;
margin: 0;
background-color: transparent;
}
.field-placeholder {
color: #999999;
font-size: 9.38rpx;
}
/* 获取验证码 右对齐 input;设计稿 454+100=554 (<570 框内),字号 20 白 */
.code-btn {
flex-shrink: 0;
font-size: 7.81rpx; /* 20 → 7.81rpx */
line-height: 14.06rpx; /* 36 → 14.06rpx */
color: #FFFFFF;
text-align: right;
padding-left: 3.91rpx;
}
.code-btn.disabled {
color: #999999;
}
/* 提交按钮 570×60 → 222.66×23.44rpx;同输入框背景 + 边框 2px rgba(217,217,217,0.8);圆角 4 → 1.56rpx;距上 (514-308-60=146) → 57.03rpx */
.submit-btn {
margin-top: 57.03rpx;
width: 222.66rpx;
height: 23.44rpx;
background-color: rgba(36, 39, 56, 0.8);
border-radius: 1.56rpx;
border-width: 0.78rpx; /* 2px → 0.78rpx */
border-style: solid;
border-color: rgba(217, 217, 217, 0.8);
align-items: center;
justify-content: center;
}
.submit-text {
color: #FFFFFF;
font-size: 9.38rpx; /* 24 → 9.38rpx */
line-height: 14.84rpx; /* 38 → 14.84rpx */
text-align: center;
}
</style>