feat:登录页面

This commit is contained in:
李伊帆
2026-09-09 14:08:40 +08:00
parent 4a0cd543af
commit f44e6ce1a1
40 changed files with 3031 additions and 11 deletions

View File

@@ -0,0 +1,262 @@
# src 目录结构规范
本文档定义 `src` 目录下的文件组织方式与命名规范。**后续新增与修改的代码必须严格遵循本规范。**
## 目录总览
```
src/
├── pages/ # 页面组件
├── components/ # 全局公共组件
├── business-cmp/ # 业务组件(页面专用)
├── static/ # 静态资源
├── api/ # 网络请求接口
├── stores/ # 状态管理(Pinia)
├── utils/ # 工具函数
├── types/ # TypeScript 类型定义
├── hooks/ # Vue Composition API Hooks
└── styles/ # 全局样式
```
---
## 各目录说明
### pages — 页面组件
存放应用页面级组件,每个页面独立一个文件夹。
| 规则 | 说明 |
|------|------|
| 文件夹命名 | **kebab-case**(小写 + 连字符) |
| 示例 | `user-login/`、`home-index/` |
**推荐结构:**
```
pages/
├── user-login/
│ ├── index.vue # 页面入口
│ └── components/ # 仅该页面使用的组件(可选)
│ └── LoginForm.vue
└── home-index/
└── index.vue
```
> 页面专属组件优先放在 `pages/<page-name>/components/` 下;若业务组件较多、需跨页面复用但又不属于全局公共组件,可使用 `business-cmp/`。
---
### components — 全局公共组件
存放可在多个页面复用的通用 UI 组件。
| 规则 | 说明 |
|------|------|
| 文件命名 | **PascalCase**(大驼峰) |
| 文件扩展名 | `.vue` |
| 示例 | `AppFooter.vue`、`InputEntry.vue` |
**适用场景:**
- 按钮、输入框、弹窗等基础 UI 封装
- 布局类组件(Header、Footer、Sidebar 等)
- 与具体业务无关、可在任意页面引用的组件
---
### business-cmp — 业务组件
存放与特定业务逻辑绑定、**不对外全局复用**的组件。
| 规则 | 说明 |
|------|------|
| 组织方式 | 按业务模块或页面分子目录 |
| 文件命名 | **PascalCase** |
| 示例 | `business-cmp/order/OrderCard.vue` |
**与 `components` 的区别:**
| 目录 | 复用范围 | 示例 |
|------|----------|------|
| `components/` | 全局、跨业务复用 | `AppFooter.vue` |
| `business-cmp/` | 特定业务/页面 | `OrderCard.vue` |
| `pages/xxx/components/` | 仅单个页面 | `LoginForm.vue` |
**选择原则:**
1. 仅一个页面使用 → `pages/<page>/components/`
2. 同一业务模块内多页面复用 → `business-cmp/<module>/`
3. 跨业务、通用 UI → `components/`
---
### static — 静态资源
存放图片、字体、图标等静态文件。
| 规则 | 说明 |
|------|------|
| 文件/文件夹命名 | **小写**(可用连字符分隔) |
| 示例 | `logo.png`、`github.svg`、`icon-arrow.png` |
**推荐结构:**
```
static/
├── images/
│ └── banner.png
├── icons/
│ └── home.svg
└── fonts/
└── custom-font.woff2
```
---
### api — 网络请求接口
封装所有后端 API 调用,按业务模块拆分文件。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case** 或按模块命名 |
| 文件扩展名 | `.ts` |
| 示例 | `user.ts`、`order.ts` |
**示例:**
```ts
// api/user.ts
export function login(data: LoginParams) {
return request.post<LoginResult>('/user/login', data)
}
```
---
### stores — 状态管理
使用 **Pinia** 管理全局状态,按功能模块拆分 Store。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case** 或 `useXxxStore` 风格 |
| 文件扩展名 | `.ts` |
| 示例 | `user.ts`、`cart.ts` |
**示例:**
```ts
// stores/user.ts
export const useUserStore = defineStore('user', () => {
// ...
})
```
---
### utils — 工具函数
存放与业务无关的纯函数工具。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case** |
| 文件扩展名 | `.ts` |
| 示例 | `format-date.ts`、`storage.ts` |
**适用场景:**
- 日期/金额格式化
- 本地存储封装
- 通用校验、防抖节流等
---
### types — TypeScript 类型定义
集中管理项目中的 TypeScript 类型与接口。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case** |
| 文件扩展名 | **`.ts`**(必须) |
| 示例 | `user.ts`、`api-response.ts` |
**示例:**
```ts
// types/user.ts
export interface UserInfo {
id: number
name: string
}
```
---
### hooks — Composition API Hooks
封装可复用的 Vue Composition API 逻辑。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case**,以 `use` 开头 |
| 文件扩展名 | `.ts` |
| 示例 | `use-auth.ts`、`use-pagination.ts` |
**示例:**
```ts
// hooks/use-auth.ts
export function useAuth() {
// ...
}
```
---
### styles — 全局样式
存放全局样式、变量、混入等。
| 规则 | 说明 |
|------|------|
| 文件命名 | **kebab-case** |
| 文件扩展名 | `.scss` / `.css` |
| 示例 | `variables.scss`、`reset.scss` |
---
## 命名规范速查表
| 类型 | 命名风格 | 示例 |
|------|----------|------|
| 页面文件夹 | kebab-case | `user-login/` |
| 全局组件文件 | PascalCase | `AppFooter.vue` |
| 业务/页面组件文件 | PascalCase | `OrderCard.vue` |
| 静态资源 | 小写 | `logo.png` |
| API / Store / Utils / Types / Hooks | kebab-case | `user.ts`、`use-auth.ts` |
| 样式文件 | kebab-case | `variables.scss` |
---
## 导入路径约定
- 全局组件:从 `@/components/` 引入
- 业务组件:从 `@/business-cmp/` 或页面相对路径引入
- API:从 `@/api/` 引入
- 类型:从 `@/types/` 引入
- 工具:从 `@/utils/` 引入
- Hooks:从 `@/hooks/` 引入
---
## 注意事项
1. **禁止**在 `components/` 中放置与具体业务强耦合的组件。
2. **禁止**在页面目录外散落页面专属组件。
3. 新增目录或调整规范前,需同步更新本文档。
4. 所有 TypeScript 类型定义统一放在 `types/`,避免在业务文件中重复定义相同类型。
5. 网络请求逻辑统一通过 `api/` 层调用,页面与组件中不直接写请求 URL。

7
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001", "@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001", "@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001",
"@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001", "@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001",
"@fontpkg/alimama-fang-yuan-ti-vf": "^1.0.5",
"vue": "3.4.21", "vue": "3.4.21",
"vue-i18n": "9.6.2", "vue-i18n": "9.6.2",
"vue-router": "4.5.1" "vue-router": "4.5.1"
@@ -3807,6 +3808,12 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fontpkg/alimama-fang-yuan-ti-vf": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@fontpkg/alimama-fang-yuan-ti-vf/-/alimama-fang-yuan-ti-vf-1.0.5.tgz",
"integrity": "sha512-lwNA/hF0tlJuqEvCZc7Y6sXLtO5IKYOmvEQkJlvRBMlTLO9jVojnkqDbLiTXCnrHQRUUgYOJTjbX1DKnbhudSg==",
"license": "SEE LICENSE IN README.md"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",

View File

@@ -28,6 +28,7 @@
"@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001", "@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001", "@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001",
"@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001", "@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001",
"@fontpkg/alimama-fang-yuan-ti-vf": "^1.0.5",
"vue": "3.4.21", "vue": "3.4.21",
"vue-i18n": "9.6.2", "vue-i18n": "9.6.2",
"vue-router": "4.5.1" "vue-router": "4.5.1"
@@ -53,4 +54,4 @@
"vite": "5.2.8", "vite": "5.2.8",
"vue-tsc": "^3.2.7" "vue-tsc": "^3.2.7"
} }
} }

View File

@@ -4,4 +4,11 @@ import { onLaunch } from '@dcloudio/uni-app'
onLaunch(() => {}) onLaunch(() => {})
</script> </script>
<style></style> <style lang="scss">
@import '@/styles/fonts.scss';
page {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
background-color: #ffffff;
}
</style>

View File

@@ -1,18 +1,57 @@
{ {
"pages": [ "pages": [
{ {
"path": "pages/index", "path": "pages/Login/splash/index",
"type": "home" "type": "home",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-login/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-forgot-password/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-register/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-register-success/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-sms-login/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/Login/user-agreement/index",
"style": {
"navigationStyle": "custom"
}
} }
], ],
"globalStyle": { "globalStyle": {
"backgroundColor": "@bgColor", "backgroundColor": "#ffffff",
"backgroundColorBottom": "@bgColorBottom", "backgroundColorBottom": "#ffffff",
"backgroundColorTop": "@bgColorTop", "backgroundColorTop": "#ffffff",
"backgroundTextStyle": "@bgTxtStyle", "backgroundTextStyle": "dark",
"navigationBarBackgroundColor": "#000000", "navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "@navTxtStyle", "navigationBarTextStyle": "black",
"navigationBarTitleText": "Uni Creator", "navigationBarTitleText": "",
"navigationStyle": "custom" "navigationStyle": "custom"
}, },
"subPackages": [] "subPackages": []

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { onMounted } from 'vue'
const SPLASH_DURATION = 3000
onMounted(() => {
setTimeout(() => {
uni.redirectTo({
url: '/pages/Login/user-login/index',
})
}, SPLASH_DURATION)
})
</script>
<template>
<view class="splash">
<view class="splash__bg-circle" />
<view class="splash__content">
<image
class="splash__logo"
src="/static/icons/logo.png"
mode="aspectFit"
/>
<text class="splash__title">
虫子智造
</text>
<text class="splash__subtitle">
智造研发一体化平台
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.splash {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, $color-splash-gradient-start 0%, $color-splash-gradient-end 100%);
}
.splash__bg-circle {
position: absolute;
top: 50%;
right: -120rpx;
width: 520rpx;
height: 520rpx;
border-radius: 50%;
background: $color-splash-circle;
transform: translateY(-50%);
}
.splash__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 38vh;
}
.splash__logo {
width: 160rpx;
height: 160rpx;
}
.splash__title {
margin-top: 40rpx;
font-family: 'Alimama FangYuanTi VF-Bold', 'Alimama FangYuanTi VF', sans-serif;
font-size: 61.07rpx;
font-weight: 700;
font-variation-settings: 'wght' 700, 'BEVL' 100;
color: $color-text-primary;
letter-spacing: 2rpx;
}
.splash__subtitle {
margin-top: 16rpx;
font-family: 'PingFangSC-Regular', 'PingFang SC', sans-serif;
font-size: 22.9rpx;
font-weight: 400;
color: $color-text-secondary;
letter-spacing: 1rpx;
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
function handleBack() {
uni.navigateBack()
}
</script>
<template>
<view class="user-agreement-page">
<view class="user-agreement-page__navbar">
<view
class="user-agreement-page__back"
@tap="handleBack"
>
<image
class="user-agreement-page__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="user-agreement-page__navbar-title">
帮助中心
</text>
<view class="user-agreement-page__navbar-placeholder" />
</view>
<scroll-view
class="user-agreement-page__scroll"
scroll-y
>
<view class="user-agreement-page__content">
<text class="user-agreement-page__doc-title">
【平台名称】用户协议
</text>
<text class="user-agreement-page__date">
更新日期:【YYYY】年【MM】月【DD】日
</text>
<text class="user-agreement-page__date">
生效日期:【YYYY】年【MM】月【DD】日
</text>
<text class="user-agreement-page__section-title">
导言
</text>
<text class="user-agreement-page__paragraph">
欢迎您使用【平台名称】!本平台由【公司全称】(以下简称"我们")运营,为您提供硬件电路设计、物联网全案服务、软件设计等一站式研发服务。在您注册成为本平台用户前,请您务必审慎阅读、充分理解本协议各条款内容,特别是以加粗或下划线标识的免除或限制责任的条款、知识产权条款、法律适用和争议解决条款。当您按照注册页面提示填写信息、阅读并同意本协议且完成全部注册程序后,即表示您已充分阅读、理解并接受本协议的全部内容。
</text>
<text class="user-agreement-page__section-title">
第一条 定义
</text>
<text class="user-agreement-page__paragraph">
1.1 本平台:指由【公司全称】运营的网站、移动应用程序及相关服务,包括但不限于【平台名称】官方网站及客户端。
</text>
<text class="user-agreement-page__paragraph">
1.2 用户:指完成本平台注册程序,使用本平台服务的自然人、法人或其他组织。
</text>
<text class="user-agreement-page__paragraph">
1.3 企业认证:指用户向本平台提交营业执照等企业资质材料,经平台审核通过的企业身份认证流程。
</text>
<text class="user-agreement-page__paragraph">
1.4 项目:指用户在本平台发起的服务需求,包括但不限于硬件设计、软件开发、物联网方案等研发类任务。
</text>
<text class="user-agreement-page__paragraph">
1.5 电子合同:指用户通过本平台在线签署的电子协议,与纸质合同具有同等法律效力。
</text>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.user-agreement-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $color-bg-page;
}
.user-agreement-page__navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 20rpx) 32rpx 20rpx;
background: $color-bg-page;
border-bottom: 1rpx solid #f0f0f0;
}
.user-agreement-page__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.user-agreement-page__back-icon {
width: 40rpx;
height: 40rpx;
}
.user-agreement-page__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.user-agreement-page__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.user-agreement-page__scroll {
flex: 1;
height: 0;
}
.user-agreement-page__content {
padding: 32rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
}
.user-agreement-page__doc-title {
display: block;
margin-bottom: 24rpx;
font-size: 36rpx;
font-weight: 700;
line-height: 1.5;
color: $color-text-primary;
}
.user-agreement-page__date {
display: block;
margin-bottom: 8rpx;
font-size: 28rpx;
line-height: 1.6;
color: $color-text-primary;
}
.user-agreement-page__section-title {
display: block;
margin-top: 40rpx;
margin-bottom: 16rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 1.5;
color: $color-text-primary;
}
.user-agreement-page__paragraph {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
line-height: 1.8;
color: $color-text-primary;
text-align: justify;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
function handleGetCode() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
</script>
<template>
<view class="code-input">
<input
class="code-input__field"
type="number"
placeholder="请输入验证码"
placeholder-class="code-input__placeholder"
:value="modelValue"
@input="onInput"
>
<view
class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }"
@tap="handleGetCode"
>
<text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.code-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 16rpx 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.code-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.code-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
.code-input__btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 64rpx;
padding: 0 24rpx;
background: $color-primary;
border-radius: 32rpx;
&--disabled {
opacity: 0.6;
}
}
.code-input__btn-text {
font-size: 24rpx;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
placeholder: string
inputType?: 'text' | 'number'
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
</script>
<template>
<view class="forgot-input">
<input
class="forgot-input__field"
:type="inputType || 'text'"
:placeholder="placeholder"
placeholder-class="forgot-input__placeholder"
:value="modelValue"
@input="onInput"
>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.forgot-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.forgot-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.forgot-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
placeholder: string
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const visible = ref(false)
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
function toggleVisible() {
visible.value = !visible.value
}
</script>
<template>
<view class="password-field">
<input
class="password-field__field"
:password="!visible"
:placeholder="placeholder"
placeholder-class="password-field__placeholder"
:value="modelValue"
@input="onInput"
>
<image
class="password-field__eye"
:src="visible ? '/static/icons/icon-eye-open.svg' : '/static/icons/icon-eye-close.svg'"
mode="aspectFit"
@tap="toggleVisible"
/>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.password-field {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.password-field__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.password-field__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
.password-field__eye {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-left: 16rpx;
}
</style>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { ref } from 'vue'
import { navigateToResultPage } from '@/utils/navigate-result'
import CodeInput from './components/CodeInput.vue'
import ForgotInput from './components/ForgotInput.vue'
import PasswordField from './components/PasswordField.vue'
const phone = ref('')
const code = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const agreed = ref(false)
function toggleAgreed() {
agreed.value = !agreed.value
}
function handleSubmit() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
if (!phone.value || !code.value || !newPassword.value || !confirmPassword.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
if (newPassword.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
return
}
navigateToResultPage('resetPassword')
}
function handleLogin() {
uni.navigateBack()
}
function handleUserAgreement() {
uni.navigateTo({
url: '/pages/Login/user-agreement/index',
})
}
</script>
<template>
<view class="forgot-page">
<view class="forgot-page__header">
<text class="forgot-page__title">
找回密码
</text>
<text class="forgot-page__subtitle">
使用手机号重新设置您的密码
</text>
</view>
<view class="forgot-page__form">
<view class="forgot-page__field">
<ForgotInput
v-model="phone"
placeholder="请输入手机号"
input-type="number"
/>
</view>
<view class="forgot-page__field">
<CodeInput v-model="code" />
</view>
<view class="forgot-page__field">
<PasswordField
v-model="newPassword"
placeholder="请输入新密码"
/>
</view>
<view class="forgot-page__field">
<PasswordField
v-model="confirmPassword"
placeholder="请再次输入新密码"
/>
</view>
<view
class="forgot-page__agreement"
@tap="toggleAgreed"
>
<view
class="forgot-page__checkbox"
:class="{ 'forgot-page__checkbox--checked': agreed }"
>
<text
v-if="agreed"
class="forgot-page__checkmark"
>
✓
</text>
</view>
<view class="forgot-page__agreement-text">
<text class="forgot-page__agreement-normal">
我已阅读并同意
</text>
<text
class="forgot-page__agreement-link"
@tap.stop="handleUserAgreement"
>
用户协议
</text>
<text class="forgot-page__agreement-normal">
和
</text>
<text class="forgot-page__agreement-link">
隐私政策
</text>
<text class="forgot-page__agreement-normal">
以及
</text>
<text class="forgot-page__agreement-link">
定制条款
</text>
</view>
</view>
<button
class="forgot-page__submit"
@tap="handleSubmit"
>
确认修改
</button>
</view>
<view class="forgot-page__footer">
<text class="forgot-page__footer-normal">
已有账号?
</text>
<text
class="forgot-page__footer-link"
@tap="handleLogin"
>
立即登录
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.forgot-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 64rpx;
background: $color-bg-page;
}
.forgot-page__header {
padding-top: 120rpx;
text-align: center;
}
.forgot-page__title {
display: block;
font-size: 56rpx;
font-weight: 700;
color: $color-text-primary;
letter-spacing: 2rpx;
}
.forgot-page__subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: 400;
color: $color-text-secondary;
}
.forgot-page__form {
width: 100%;
margin-top: 80rpx;
box-sizing: border-box;
}
.forgot-page__field {
width: 100%;
margin-top: 24rpx;
box-sizing: border-box;
}
.forgot-page__agreement {
display: flex;
align-items: flex-start;
margin-top: 32rpx;
}
.forgot-page__checkbox {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
margin-top: 4rpx;
margin-right: 12rpx;
border: 2rpx solid #cccccc;
border-radius: 50%;
transition: all 0.2s;
&--checked {
background: $color-primary;
border-color: $color-primary;
}
}
.forgot-page__checkmark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.forgot-page__agreement-text {
flex: 1;
font-size: 24rpx;
line-height: 1.6;
}
.forgot-page__agreement-normal {
color: $color-text-secondary;
}
.forgot-page__agreement-link {
color: $color-text-link;
}
.forgot-page__submit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border: none;
border-radius: 48rpx;
line-height: 96rpx;
&::after {
border: none;
}
}
.forgot-page__footer {
display: flex;
align-items: center;
justify-content: center;
margin-top: auto;
padding: 60rpx 0 calc(60rpx + env(safe-area-inset-bottom));
}
.forgot-page__footer-normal {
font-size: 28rpx;
color: $color-text-secondary;
}
.forgot-page__footer-link {
margin-left: 4rpx;
font-size: 28rpx;
color: $color-text-link;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
icon: string
placeholder: string
inputType?: 'text' | 'number'
password?: boolean
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
</script>
<template>
<view class="login-input">
<image
class="login-input__icon"
:src="icon"
mode="aspectFit"
/>
<input
class="login-input__field"
:type="inputType || 'text'"
:password="password"
:placeholder="placeholder"
placeholder-class="login-input__placeholder"
:value="modelValue"
@input="onInput"
>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.login-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.login-input__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.login-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.login-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { ref } from 'vue'
import { navigateToResultPage } from '@/utils/navigate-result'
import LoginInput from './components/LoginInput.vue'
const phone = ref('')
const password = ref('')
const agreed = ref(false)
function toggleAgreed() {
agreed.value = !agreed.value
}
function handleLogin() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
if (!phone.value || !password.value) {
uni.showToast({ title: '请输入手机号和密码', icon: 'none' })
return
}
// TODO: 接入登录接口后,在接口返回失败时调用
navigateToResultPage('loginFailed')
}
function handleForgotPassword() {
uni.navigateTo({
url: '/pages/Login/user-forgot-password/index',
})
}
function handleSmsLogin() {
uni.navigateTo({
url: '/pages/Login/user-sms-login/index',
})
}
function handleRegister() {
uni.navigateTo({
url: '/pages/Login/user-register/index',
})
}
function handleUserAgreement() {
uni.navigateTo({
url: '/pages/Login/user-agreement/index',
})
}
</script>
<template>
<view class="login-page">
<view class="login-page__header">
<text class="login-page__title">
欢迎回来
</text>
<text class="login-page__subtitle">
使用密码登录您的账号
</text>
</view>
<view class="login-page__form">
<LoginInput
v-model="phone"
icon="/static/icons/icon-phone.png"
placeholder="请输入手机号"
input-type="number"
/>
<view class="login-page__password-group">
<LoginInput
v-model="password"
icon="/static/icons/icon-shield-check.png"
placeholder="请输入密码"
password
/>
<text
class="login-page__forgot"
@tap="handleForgotPassword"
>
忘记密码?
</text>
</view>
<view
class="login-page__agreement"
@tap="toggleAgreed"
>
<view
class="login-page__checkbox"
:class="{ 'login-page__checkbox--checked': agreed }"
>
<text
v-if="agreed"
class="login-page__checkmark"
>
✓
</text>
</view>
<view class="login-page__agreement-text">
<text class="login-page__agreement-normal">
我已阅读并同意
</text>
<text
class="login-page__agreement-link"
@tap.stop="handleUserAgreement"
>
用户协议
</text>
<text class="login-page__agreement-normal">
和
</text>
<text class="login-page__agreement-link">
隐私政策
</text>
<text class="login-page__agreement-normal">
以及
</text>
<text class="login-page__agreement-link">
定制条款
</text>
</view>
</view>
<button
class="login-page__submit"
@tap="handleLogin"
>
登录
</button>
</view>
<view class="login-page__other">
<view class="login-page__divider">
<view class="login-page__divider-line" />
<text class="login-page__divider-text">
其他登录方式
</text>
<view class="login-page__divider-line" />
</view>
<view
class="login-page__sms"
@tap="handleSmsLogin"
>
<view class="login-page__sms-icon-wrap">
<image
class="login-page__sms-icon"
src="/static/icons/icon-sms-login.png"
mode="aspectFit"
/>
</view>
<text class="login-page__sms-text">
验证码登录
</text>
</view>
</view>
<view class="login-page__footer">
<text class="login-page__footer-normal">
没有账号?
</text>
<text
class="login-page__footer-link"
@tap="handleRegister"
>
立即注册
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.login-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 64rpx;
background: $color-bg-page;
}
.login-page__header {
padding-top: 120rpx;
text-align: center;
}
.login-page__title {
display: block;
font-size: 56rpx;
font-weight: 700;
color: $color-text-primary;
letter-spacing: 2rpx;
}
.login-page__subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: 400;
color: $color-text-secondary;
}
.login-page__form {
width: 100%;
margin-top: 80rpx;
box-sizing: border-box;
}
.login-page__password-group {
margin-top: 24rpx;
}
.login-page__forgot {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.login-page__agreement {
display: flex;
align-items: flex-start;
margin-top: 32rpx;
}
.login-page__checkbox {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
margin-top: 4rpx;
margin-right: 12rpx;
border: 2rpx solid #cccccc;
border-radius: 50%;
transition: all 0.2s;
&--checked {
background: $color-primary;
border-color: $color-primary;
}
}
.login-page__checkmark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.login-page__agreement-text {
flex: 1;
font-size: 24rpx;
line-height: 1.6;
}
.login-page__agreement-normal {
color: $color-text-secondary;
}
.login-page__agreement-link {
color: $color-text-link;
}
.login-page__submit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border: none;
border-radius: 48rpx;
line-height: 96rpx;
&::after {
border: none;
}
}
.login-page__other {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.login-page__divider {
display: flex;
align-items: center;
width: 100%;
}
.login-page__divider-line {
flex: 1;
height: 1rpx;
background: #e8e8e8;
}
.login-page__divider-text {
padding: 0 24rpx;
font-size: 24rpx;
color: $color-text-secondary;
white-space: nowrap;
}
.login-page__sms {
display: flex;
flex-direction: column;
align-items: center;
width: fit-content;
margin-top: 48rpx;
}
.login-page__sms-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
background: #B6CDFF;
border-radius: 50%;
}
.login-page__sms-icon {
width: 48rpx;
height: 48rpx;
}
.login-page__sms-text {
margin-top: 16rpx;
font-size: 24rpx;
color: #999999;
}
.login-page__footer {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
.login-page__footer-normal {
font-size: 28rpx;
color: $color-text-secondary;
}
.login-page__footer-link {
margin-left: 4rpx;
font-size: 28rpx;
color: $color-text-link;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
RESULT_PAGE_CONFIG,
RESULT_REDIRECT_DELAY,
type ResultPageType,
} from '@/types/result-page'
const pageType = ref<ResultPageType>('register')
const countdown = ref(RESULT_REDIRECT_DELAY / 1000)
let timer: ReturnType<typeof setInterval> | null = null
const pageConfig = computed(() => RESULT_PAGE_CONFIG[pageType.value])
const title = computed(() => pageConfig.value.title)
const subtitle = computed(() => `${countdown.value}秒后自动跳转至${pageConfig.value.subtitleTarget}`)
onLoad((options) => {
const type = options?.type
if (type === 'resetPassword' || type === 'reset-password') {
pageType.value = 'resetPassword'
return
}
if (type === 'loginFailed') {
pageType.value = 'loginFailed'
}
})
onMounted(() => {
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) {
if (timer) {
clearInterval(timer)
timer = null
}
uni.redirectTo({
url: pageConfig.value.redirectUrl,
})
}
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<template>
<view class="result-page">
<view class="result-page__content">
<image
class="result-page__icon"
:src="pageConfig.icon"
mode="aspectFit"
/>
<text class="result-page__title">
{{ title }}
</text>
<text class="result-page__subtitle">
{{ subtitle }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.result-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: $color-bg-page;
}
.result-page__content {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 64rpx;
}
.result-page__icon {
width: 160rpx;
height: 160rpx;
}
.result-page__title {
margin-top: 48rpx;
font-size: 40rpx;
font-weight: 700;
color: $color-text-primary;
letter-spacing: 1rpx;
}
.result-page__subtitle {
margin-top: 24rpx;
font-size: 28rpx;
font-weight: 400;
color: $color-text-secondary;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
function handleGetCode() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
</script>
<template>
<view class="code-input">
<image
class="code-input__icon"
src="/static/icons/icon-shield-check.png"
mode="aspectFit"
/>
<input
class="code-input__field"
type="number"
placeholder="请输入验证码"
placeholder-class="code-input__placeholder"
:value="modelValue"
@input="onInput"
>
<view
class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }"
@tap="handleGetCode"
>
<text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.code-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 16rpx 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.code-input__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.code-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.code-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
.code-input__btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 64rpx;
padding: 0 24rpx;
background: $color-primary;
border-radius: 32rpx;
&--disabled {
opacity: 0.6;
}
}
.code-input__btn-text {
font-size: 24rpx;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
placeholder: string
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const visible = ref(false)
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
function toggleVisible() {
visible.value = !visible.value
}
</script>
<template>
<view class="password-field">
<image
class="password-field__icon"
src="/static/icons/icon-lock.png"
mode="aspectFit"
/>
<input
class="password-field__field"
:password="!visible"
:placeholder="placeholder"
placeholder-class="password-field__placeholder"
:value="modelValue"
@input="onInput"
>
<image
class="password-field__eye"
:src="visible ? '/static/icons/icon-eye-open.svg' : '/static/icons/icon-eye-close.svg'"
mode="aspectFit"
@tap="toggleVisible"
/>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.password-field {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.password-field__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.password-field__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.password-field__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
.password-field__eye {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-left: 16rpx;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getPasswordStrength } from '@/utils/password-strength'
defineOptions({
options: {
virtualHost: true,
},
})
const props = defineProps<{
password: string
}>()
const strength = computed(() => getPasswordStrength(props.password))
const showIndicator = computed(() => props.password.length > 0)
</script>
<template>
<view
v-if="showIndicator"
class="password-strength"
>
<view class="password-strength__row">
<view class="password-strength__bars">
<view
v-for="index in 4"
:key="index"
class="password-strength__bar"
:class="{ 'password-strength__bar--active': index <= strength.level }"
:style="index <= strength.level ? { background: strength.color } : undefined"
/>
</view>
<text
class="password-strength__label"
:style="{ color: strength.color }"
>
{{ strength.label }}
</text>
</view>
<view
v-if="strength.isTooWeak"
class="password-strength__error"
>
<view class="password-strength__error-icon">
<text class="password-strength__error-mark">
<img src="/static/icons/icon-mark.png" alt="error" />
</text>
</view>
<text class="password-strength__error-text">
密码等级过低,请重新输入
</text>
</view>
</view>
</template>
<style scoped lang="scss">
.password-strength {
width: 100%;
margin-top: 16rpx;
}
.password-strength__row {
display: flex;
align-items: center;
}
.password-strength__bars {
display: flex;
flex: 1;
gap: 12rpx;
}
.password-strength__bar {
flex: 1;
height: 8rpx;
background: #e8e8e8;
border-radius: 4rpx;
}
.password-strength__label {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 24rpx;
font-weight: 500;
}
.password-strength__error {
display: flex;
align-items: center;
margin-top: 12rpx;
}
.password-strength__error-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
margin-right: 8rpx;
// background: #ff4d4f;
border-radius: 6rpx;
}
.password-strength__error-mark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.password-strength__error-text {
font-size: 24rpx;
color: #ff4d4f;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
icon: string
placeholder: string
inputType?: 'text' | 'number'
password?: boolean
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
</script>
<template>
<view class="register-input">
<image
class="register-input__icon"
:src="icon"
mode="aspectFit"
/>
<input
class="register-input__field"
:type="inputType || 'text'"
:password="password"
:placeholder="placeholder"
placeholder-class="register-input__placeholder"
:value="modelValue"
@input="onInput"
>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.register-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.register-input__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.register-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.register-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
</style>

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref } from 'vue'
import { getPasswordStrength } from '@/utils/password-strength'
import { navigateToResultPage } from '@/utils/navigate-result'
import CodeInput from './components/CodeInput.vue'
import PasswordField from './components/PasswordField.vue'
import PasswordStrength from './components/PasswordStrength.vue'
import RegisterInput from './components/RegisterInput.vue'
const phone = ref('')
const code = ref('')
const password = ref('')
const confirmPassword = ref('')
const agreed = ref(false)
function toggleAgreed() {
agreed.value = !agreed.value
}
function handleRegister() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
if (!phone.value || !code.value || !password.value || !confirmPassword.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
return
}
if (getPasswordStrength(password.value).isTooWeak) {
uni.showToast({ title: '密码等级过低,请重新输入', icon: 'none' })
return
}
navigateToResultPage('register')
}
function handleLogin() {
uni.navigateBack()
}
function handleUserAgreement() {
uni.navigateTo({
url: '/pages/Login/user-agreement/index',
})
}
</script>
<template>
<view class="register-page">
<view class="register-page__header">
<text class="register-page__title">
创建账号
</text>
<text class="register-page__subtitle">
注册后即可开始使用全部功能
</text>
</view>
<view class="register-page__form">
<view class="register-page__field">
<RegisterInput
v-model="phone"
icon="/static/icons/icon-phone.png"
placeholder="请输入手机号"
input-type="number"
/>
</view>
<view class="register-page__field">
<CodeInput v-model="code" />
</view>
<view class="register-page__field">
<PasswordField
v-model="password"
placeholder="设置密码"
/>
<PasswordStrength :password="password" />
</view>
<view class="register-page__field">
<PasswordField
v-model="confirmPassword"
placeholder="再次确认密码"
/>
</view>
<view
class="register-page__agreement"
@tap="toggleAgreed"
>
<view
class="register-page__checkbox"
:class="{ 'register-page__checkbox--checked': agreed }"
>
<text
v-if="agreed"
class="register-page__checkmark"
>
✓
</text>
</view>
<view class="register-page__agreement-text">
<text class="register-page__agreement-normal">
我已阅读并同意
</text>
<text
class="register-page__agreement-link"
@tap.stop="handleUserAgreement"
>
用户协议
</text>
<text class="register-page__agreement-normal">
和
</text>
<text class="register-page__agreement-link">
隐私政策
</text>
<text class="register-page__agreement-normal">
以及
</text>
<text class="register-page__agreement-link">
定制条款
</text>
</view>
</view>
<button
class="register-page__submit"
@tap="handleRegister"
>
注册
</button>
</view>
<view class="register-page__footer">
<text class="register-page__footer-normal">
已有账号?
</text>
<text
class="register-page__footer-link"
@tap="handleLogin"
>
立即登录
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.register-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 64rpx;
background: $color-bg-page;
}
.register-page__header {
padding-top: 120rpx;
text-align: center;
}
.register-page__title {
display: block;
font-size: 56rpx;
font-weight: 700;
color: $color-text-primary;
letter-spacing: 2rpx;
}
.register-page__subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: 400;
color: $color-text-secondary;
}
.register-page__form {
width: 100%;
margin-top: 80rpx;
box-sizing: border-box;
}
.register-page__field {
width: 100%;
margin-top: 24rpx;
box-sizing: border-box;
&:first-child {
margin-top: 0;
}
}
.register-page__agreement {
display: flex;
align-items: flex-start;
margin-top: 32rpx;
}
.register-page__checkbox {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
margin-top: 4rpx;
margin-right: 12rpx;
border: 2rpx solid #cccccc;
border-radius: 50%;
transition: all 0.2s;
&--checked {
background: $color-primary;
border-color: $color-primary;
}
}
.register-page__checkmark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.register-page__agreement-text {
flex: 1;
font-size: 24rpx;
line-height: 1.6;
}
.register-page__agreement-normal {
color: $color-text-secondary;
}
.register-page__agreement-link {
color: $color-text-link;
}
.register-page__submit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border: none;
border-radius: 48rpx;
line-height: 96rpx;
&::after {
border: none;
}
}
.register-page__footer {
display: flex;
align-items: center;
justify-content: center;
margin-top: auto;
padding: 60rpx 0 calc(60rpx + env(safe-area-inset-bottom));
}
.register-page__footer-normal {
font-size: 28rpx;
color: $color-text-secondary;
}
.register-page__footer-link {
margin-left: 4rpx;
font-size: 28rpx;
color: $color-text-link;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
function handleGetCode() {
if (countdown.value > 0)
return
uni.showToast({ title: '验证码已发送', icon: 'none' })
countdown.value = 60
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
</script>
<template>
<view class="code-input">
<image
class="code-input__icon"
src="/static/icons/icon-shield-check.png"
mode="aspectFit"
/>
<input
class="code-input__field"
type="number"
placeholder="请输入验证码"
placeholder-class="code-input__placeholder"
:value="modelValue"
@input="onInput"
>
<view
class="code-input__btn"
:class="{ 'code-input__btn--disabled': countdown > 0 }"
@tap="handleGetCode"
>
<text class="code-input__btn-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.code-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 16rpx 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.code-input__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.code-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.code-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
.code-input__btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 64rpx;
padding: 0 24rpx;
background: $color-primary;
border-radius: 32rpx;
&--disabled {
opacity: 0.6;
}
}
.code-input__btn-text {
font-size: 24rpx;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
defineOptions({
options: {
virtualHost: true,
},
})
defineProps<{
icon: string
placeholder: string
inputType?: 'text' | 'number'
password?: boolean
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(e: { detail: { value: string } }) {
emit('update:modelValue', e.detail.value)
}
</script>
<template>
<view class="login-input">
<image
class="login-input__icon"
:src="icon"
mode="aspectFit"
/>
<input
class="login-input__field"
:type="inputType || 'text'"
:password="password"
:placeholder="placeholder"
placeholder-class="login-input__placeholder"
:value="modelValue"
@input="onInput"
>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.login-input {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.login-input__icon {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.login-input__field {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $color-text-primary;
background: transparent;
}
.login-input__placeholder {
font-size: 30rpx;
color: $color-text-placeholder;
}
</style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { ref } from 'vue'
import CodeInput from './components/CodeInput.vue'
import LoginInput from './components/LoginInput.vue'
const phone = ref('')
const code = ref('')
const agreed = ref(false)
function toggleAgreed() {
agreed.value = !agreed.value
}
function handleLogin() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
if (!phone.value || !code.value) {
uni.showToast({ title: '请输入手机号和验证码', icon: 'none' })
return
}
}
function handlePasswordLogin() {
uni.navigateBack()
}
function handleRegister() {
uni.navigateTo({
url: '/pages/Login/user-register/index',
})
}
function handleUserAgreement() {
uni.navigateTo({
url: '/pages/Login/user-agreement/index',
})
}
</script>
<template>
<view class="sms-login-page">
<view class="sms-login-page__header">
<text class="sms-login-page__title">
欢迎回来
</text>
<text class="sms-login-page__subtitle">
使用手机号登录您的账号
</text>
</view>
<view class="sms-login-page__form">
<LoginInput
v-model="phone"
icon="/static/icons/icon-phone.png"
placeholder="请输入手机号"
input-type="number"
/>
<view class="sms-login-page__code-group">
<CodeInput v-model="code" />
</view>
<view
class="sms-login-page__agreement"
@tap="toggleAgreed"
>
<view
class="sms-login-page__checkbox"
:class="{ 'sms-login-page__checkbox--checked': agreed }"
>
<text
v-if="agreed"
class="sms-login-page__checkmark"
>
✓
</text>
</view>
<view class="sms-login-page__agreement-text">
<text class="sms-login-page__agreement-normal">
我已阅读并同意
</text>
<text
class="sms-login-page__agreement-link"
@tap.stop="handleUserAgreement"
>
用户协议
</text>
<text class="sms-login-page__agreement-normal">
和
</text>
<text class="sms-login-page__agreement-link">
隐私政策
</text>
<text class="sms-login-page__agreement-normal">
以及
</text>
<text class="sms-login-page__agreement-link">
定制条款
</text>
</view>
</view>
<button
class="sms-login-page__submit"
@tap="handleLogin"
>
登录
</button>
</view>
<view class="sms-login-page__other">
<view class="sms-login-page__divider">
<view class="sms-login-page__divider-line" />
<text class="sms-login-page__divider-text">
其他登录方式
</text>
<view class="sms-login-page__divider-line" />
</view>
<view
class="sms-login-page__password"
@tap="handlePasswordLogin"
>
<view class="sms-login-page__password-icon-wrap">
<image
class="sms-login-page__password-icon"
src="/static/icons/icon-password-login.png"
mode="aspectFit"
/>
</view>
<text class="sms-login-page__password-text">
密码登录
</text>
</view>
</view>
<view class="sms-login-page__footer">
<text class="sms-login-page__footer-normal">
没有账号?
</text>
<text
class="sms-login-page__footer-link"
@tap="handleRegister"
>
立即注册
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.sms-login-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 64rpx;
background: $color-bg-page;
}
.sms-login-page__header {
padding-top: 120rpx;
text-align: center;
}
.sms-login-page__title {
display: block;
font-size: 56rpx;
font-weight: 700;
color: $color-text-primary;
letter-spacing: 2rpx;
}
.sms-login-page__subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: 400;
color: $color-text-secondary;
}
.sms-login-page__form {
width: 100%;
margin-top: 80rpx;
box-sizing: border-box;
}
.sms-login-page__code-group {
margin-top: 24rpx;
}
.sms-login-page__agreement {
display: flex;
align-items: flex-start;
margin-top: 32rpx;
}
.sms-login-page__checkbox {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
margin-top: 4rpx;
margin-right: 12rpx;
border: 2rpx solid #cccccc;
border-radius: 50%;
transition: all 0.2s;
&--checked {
background: $color-primary;
border-color: $color-primary;
}
}
.sms-login-page__checkmark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.sms-login-page__agreement-text {
flex: 1;
font-size: 24rpx;
line-height: 1.6;
}
.sms-login-page__agreement-normal {
color: $color-text-secondary;
}
.sms-login-page__agreement-link {
color: $color-text-link;
}
.sms-login-page__submit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border: none;
border-radius: 48rpx;
line-height: 96rpx;
&::after {
border: none;
}
}
.sms-login-page__other {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.sms-login-page__divider {
display: flex;
align-items: center;
width: 100%;
}
.sms-login-page__divider-line {
flex: 1;
height: 1rpx;
background: #e8e8e8;
}
.sms-login-page__divider-text {
padding: 0 24rpx;
font-size: 24rpx;
color: $color-text-secondary;
white-space: nowrap;
}
.sms-login-page__password {
display: flex;
flex-direction: column;
align-items: center;
width: fit-content;
margin-top: 48rpx;
}
.sms-login-page__password-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
background: #B6CDFF;
border-radius: 50%;
}
.sms-login-page__password-icon {
width: 48rpx;
height: 48rpx;
}
.sms-login-page__password-text {
margin-top: 16rpx;
font-size: 24rpx;
color: #999999;
}
.sms-login-page__footer {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
.sms-login-page__footer-normal {
font-size: 28rpx;
color: $color-text-secondary;
}
.sms-login-page__footer-link {
margin-left: 4rpx;
font-size: 28rpx;
color: $color-text-link;
}
</style>

Binary file not shown.

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3L21 21" stroke="#999999" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10.58 10.58C10.21 10.95 10 11.45 10 12C10 13.1 10.9 14 12 14C12.55 14 13.05 13.79 13.42 13.42" stroke="#999999" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.88 5.09C10.56 5.03 11.27 5 12 5C17 5 21.27 8.11 23 12.5C22.39 14.32 21.22 15.92 19.77 17.18" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.61 6.61C4.09 8.17 2.36 10.27 1 12.5C2.73 16.89 7 20 12 20C13.55 20 15.03 19.69 16.39 19.14" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5C7 5 2.73 8.11 1 12.5C2.73 16.89 7 20 12 20C17 20 21.27 16.89 23 12.5C21.27 8.11 17 5 12 5Z" stroke="#999999" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="12" cy="12.5" r="3" stroke="#999999" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

View File

@@ -0,0 +1,5 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="60" r="60" fill="#FF4D4F"/>
<path d="M60 34V68" stroke="#FFFFFF" stroke-width="8" stroke-linecap="round"/>
<circle cx="60" cy="84" r="4" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

BIN
src/static/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

12
src/styles/fonts.scss Normal file
View File

@@ -0,0 +1,12 @@
@font-face {
font-family: 'Alimama FangYuanTi VF';
src: url('/static/fonts/AlimamaFangYuanTiVF-Thin.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Alimama FangYuanTi VF-Bold';
src: url('/static/fonts/AlimamaFangYuanTiVF-Thin.woff2') format('woff2');
font-display: swap;
font-weight: 700;
}

15
src/styles/variables.scss Normal file
View File

@@ -0,0 +1,15 @@
// 设计规范色值
$color-primary: #2563EB;
$color-primary-light: #e8f0ff;
$color-text-primary: #1a1a1a;
$color-text-secondary: #999999;
$color-text-placeholder: #b0b0b0;
$color-text-link: #2563EB;
$color-bg-page: #ffffff;
$color-bg-input: #f2f3f5;
$color-splash-gradient-start: #f0f7ff;
$color-splash-gradient-end: #deedff;
$color-splash-circle: rgba(180, 210, 255, 0.35);

40
src/types/result-page.ts Normal file
View File

@@ -0,0 +1,40 @@
export type ResultPageType = 'register' | 'resetPassword' | 'loginFailed'
export interface ResultPageConfig {
title: string
subtitleTarget: string
icon: string
redirectUrl: string
}
export const RESULT_PAGE_CONFIG: Record<ResultPageType, ResultPageConfig> = {
register: {
title: '注册成功,请登录',
subtitleTarget: '登录页面',
icon: '/static/icons/icon-register-success.png',
redirectUrl: '/pages/Login/user-login/index',
},
resetPassword: {
title: '修改成功,请登录',
subtitleTarget: '登录页面',
icon: '/static/icons/icon-register-success.png',
redirectUrl: '/pages/Login/user-login/index',
},
loginFailed: {
title: '登录失败,请先注册账号',
subtitleTarget: '注册页面',
icon: '/static/icons/icon-login-failed.svg',
redirectUrl: '/pages/Login/user-register/index',
},
}
export const RESULT_PAGE_ROUTE = '/pages/Login/user-register-success/index'
export const RESULT_REDIRECT_DELAY = 3000
// 兼容旧命名
export type SuccessPageType = ResultPageType
export const SUCCESS_PAGE_CONFIG = RESULT_PAGE_CONFIG
export const SUCCESS_PAGE_ROUTE = RESULT_PAGE_ROUTE
export const SUCCESS_REDIRECT_URL = RESULT_PAGE_CONFIG.register.redirectUrl
export const SUCCESS_REDIRECT_DELAY = RESULT_REDIRECT_DELAY

View File

@@ -0,0 +1,18 @@
import {
RESULT_PAGE_ROUTE,
type ResultPageType,
} from '@/types/result-page'
export function navigateToResultPage(type: ResultPageType) {
const url = `${RESULT_PAGE_ROUTE}?type=${type}`
uni.redirectTo({
url,
fail: () => {
uni.navigateTo({ url })
},
})
}
// 兼容旧命名
export { navigateToResultPage as navigateToSuccessPage }

View File

@@ -0,0 +1,42 @@
export type PasswordStrengthLevel = 0 | 1 | 2 | 3 | 4
export interface PasswordStrengthResult {
level: PasswordStrengthLevel
label: string
color: string
isTooWeak: boolean
}
export function getPasswordStrength(password: string): PasswordStrengthResult {
if (!password) {
return { level: 0, label: '', color: '', isTooWeak: false }
}
const hasLower = /[a-z]/.test(password)
const hasUpper = /[A-Z]/.test(password)
const hasDigit = /\d/.test(password)
const hasSpecial = /[^a-zA-Z0-9]/.test(password)
const typeCount = [hasLower, hasUpper, hasDigit, hasSpecial].filter(Boolean).length
let level: PasswordStrengthLevel = 1
if (password.length >= 8 && typeCount >= 3 && hasDigit && (hasLower || hasUpper)) {
level = password.length >= 12 ? 4 : 3
}
else if (password.length >= 8 && typeCount >= 2) {
level = 2
}
if (password.length < 8 || /^\d+$/.test(password) || /^[a-zA-Z]+$/.test(password)) {
level = 1
}
const config: Record<Exclude<PasswordStrengthLevel, 0>, Omit<PasswordStrengthResult, 'level'>> = {
1: { label: '弱', color: '#FF4D4F', isTooWeak: true },
2: { label: '中', color: '#FAAD14', isTooWeak: false },
3: { label: '强', color: '#52C41A', isTooWeak: false },
4: { label: '很强', color: '#52C41A', isTooWeak: false },
}
return { level, ...config[level] }
}