feat: 邮箱验证码注册+登录协议勾选
- 后端: VerificationCode模型+send-code API+register增加verifyCode校验 - 前端: 注册表单增加验证码输入框+60s倒计时获取按钮 - 前端: 登录表单增加用户协议/隐私政策勾选 - 前端: 登录按钮增加disabled状态(未勾选时不可点击) - 后端: nodemailer邮件发送工具(可配置SMTP,未配置时返回devCode)
This commit is contained in:
@@ -15,7 +15,8 @@
|
||||
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
||||
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
||||
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}}
|
||||
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}},
|
||||
{"path": "pages/agreement/agreement", "style": {"navigationBarTitleText": "协议", "navigationStyle": "custom"}}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<view class="page-agreement page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
<view class="agreement-header">
|
||||
<text class="back-btn" @click="goBack">← 返回</text>
|
||||
<text class="header-title">{{ pageTitle }}</text>
|
||||
</view>
|
||||
<scroll-view class="agreement-content" scroll-y>
|
||||
<view v-if="type === 'terms'" class="content-block">
|
||||
<h2>宇之然 AI 维度 用户协议</h2>
|
||||
<p>更新日期:2026年7月12日</p>
|
||||
<p>欢迎使用宇之然 AI 维度(以下简称"本服务")。本协议是您与北京宇之然科技中心之间关于使用本服务的法律协议。</p>
|
||||
|
||||
<h3>一、服务说明</h3>
|
||||
<p>1.1 本服务提供AI知识探索、问答、趋势资讯等内容服务。</p>
|
||||
<p>1.2 本服务部分功能免费使用,高级功能需付费订阅。</p>
|
||||
<p>1.3 我们保留随时修改、暂停或终止服务的权利,但会提前通知用户。</p>
|
||||
|
||||
<h3>二、用户账户</h3>
|
||||
<p>2.1 您注册时需提供真实、准确的邮箱信息。</p>
|
||||
<p>2.2 您有责任保管账户密码,因密码泄露造成的损失由您自行承担。</p>
|
||||
<p>2.3 每个邮箱只能注册一个账户,禁止恶意注册多个账户。</p>
|
||||
|
||||
<h3>三、使用规范</h3>
|
||||
<p>3.1 您不得利用本服务从事任何违法违规活动。</p>
|
||||
<p>3.2 您不得对本服务进行反向工程、破解或攻击。</p>
|
||||
<p>3.3 AI 生成内容仅供辅助参考,不构成专业建议,发布前请自行审核。</p>
|
||||
|
||||
<h3>四、知识产权</h3>
|
||||
<p>4.1 本服务的内容(包括但不限于文字、图片、代码)的知识产权归北京宇之然科技中心所有。</p>
|
||||
<p>4.2 您使用本服务生成的内容,其知识产权归您所有,但您授予我们非独占的展示授权。</p>
|
||||
|
||||
<h3>五、付费订阅</h3>
|
||||
<p>5.1 订阅费用按所选套餐支付,订阅后自动续费直至您取消。</p>
|
||||
<p>5.2 您可随时在账户设置中取消自动续费,取消后当前周期内仍可继续使用。</p>
|
||||
<p>5.3 订阅费用一经支付,不支持退款,除非因服务故障导致无法正常使用。</p>
|
||||
|
||||
<h3>六、免责声明</h3>
|
||||
<p>6.1 本服务按"现状"提供,不保证服务不会中断或无错误。</p>
|
||||
<p>6.2 AI 生成内容可能存在偏差或不准确,用户应自行判断和使用。</p>
|
||||
<p>6.3 因不可抗力因素导致的服务中断,我们不承担责任。</p>
|
||||
|
||||
<h3>七、协议变更</h3>
|
||||
<p>7.1 我们可能修改本协议,修改后的协议将在页面公布后生效。</p>
|
||||
<p>7.2 您继续使用本服务即视为接受修改后的协议。</p>
|
||||
</view>
|
||||
|
||||
<view v-if="type === 'privacy'" class="content-block">
|
||||
<h2>宇之然 AI 维度 隐私政策</h2>
|
||||
<p>更新日期:2026年7月12日</p>
|
||||
<p>北京宇之然科技中心(以下简称"我们")重视您的隐私。本隐私政策说明了我们如何收集、使用和保护您的个人信息。</p>
|
||||
|
||||
<h3>一、信息收集</h3>
|
||||
<p>1.1 您主动提供的信息:注册时提供的邮箱、昵称、头像;提交的意见反馈内容。</p>
|
||||
<p>1.2 使用过程中产生的信息:使用记录、收藏内容、AI问答记录、学习进度。</p>
|
||||
<p>1.3 微信小程序环境:我们通过微信登录获取您的微信昵称和头像(需您授权)。</p>
|
||||
<p>1.4 设备信息:操作系统版本、设备型号、网络类型(仅用于服务优化)。</p>
|
||||
|
||||
<h3>二、信息使用</h3>
|
||||
<p>2.1 提供、维护和改进本服务。</p>
|
||||
<p>2.2 向您发送服务相关的通知(如订阅到期提醒)。</p>
|
||||
<p>2.3 分析使用趋势,优化产品体验。</p>
|
||||
<p>2.4 我们不会将您的个人信息用于本政策未载明的其他用途。</p>
|
||||
|
||||
<h3>三、信息保护</h3>
|
||||
<p>3.1 我们采用行业标准的安全措施保护您的数据,包括SSL加密传输、数据加密存储。</p>
|
||||
<p>3.2 您的密码经过加密存储,我们无法获取您的原始密码。</p>
|
||||
<p>3.3 尽管我们尽力保护您的数据,但没有任何网络传输或存储方式是100%安全的。</p>
|
||||
|
||||
<h3>四、信息共享</h3>
|
||||
<p>4.1 我们不会将您的个人信息出售给第三方。</p>
|
||||
<p>4.2 我们可能在以下情况共享信息:法律要求、保护我们的合法权益、获得您的明确同意。</p>
|
||||
<p>4.3 我们使用的第三方服务(如微信登录、支付服务)会按照其各自的隐私政策处理数据。</p>
|
||||
|
||||
<h3>五、数据存储与跨境传输</h3>
|
||||
<p>5.1 您的数据存储在中国境内的服务器上。</p>
|
||||
<p>5.2 我们不会主动将您的数据传输至境外。</p>
|
||||
|
||||
<h3>六、您的权利</h3>
|
||||
<p>6.1 您有权查阅、更正、删除您的个人信息。</p>
|
||||
<p>6.2 您可以在账户设置中申请注销账户,注销后我们将删除您的个人信息。</p>
|
||||
<p>6.3 您可以通过邮箱 contact@yuzhiran.com 联系我们行使上述权利。</p>
|
||||
|
||||
<h3>七、政策更新</h3>
|
||||
<p>7.1 我们可能更新本隐私政策,更新后的政策将在页面公布后生效。</p>
|
||||
<p>7.2 重大变更我们会通过邮件或站内通知告知您。</p>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const type = ref('terms')
|
||||
onLoad((options) => {
|
||||
type.value = options.type || 'terms'
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
return type.value === 'terms' ? '用户协议' : '隐私政策'
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onLoad(fn) {
|
||||
const pages = getCurrentPages()
|
||||
const page = pages[pages.length - 1]
|
||||
if (page && page.$page) {
|
||||
fn(page.$page.options || {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-agreement {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
.agreement-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
.back-btn {
|
||||
font-size: 14px;
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.agreement-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.content-block {
|
||||
h2 { font-size: 20px; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
|
||||
h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 20px 0 10px; }
|
||||
p { font-size: 13px; color: var(--text-secondary); line-height: 1.8; margin-bottom: 6px; }
|
||||
}
|
||||
</style>
|
||||
+222
-214
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<view class="login-page page-layout">
|
||||
<!-- 背景装饰 -->
|
||||
<view class="bg-orb orb-1"></view>
|
||||
<view class="bg-orb orb-2"></view>
|
||||
<view class="bg-orb orb-3"></view>
|
||||
|
||||
<view class="login-container">
|
||||
<!-- Logo -->
|
||||
<view class="logo-wrap">
|
||||
<view class="logo-ring"></view>
|
||||
<text class="logo-text">AI</text>
|
||||
@@ -14,7 +12,6 @@
|
||||
<text class="app-title">宇之然 AI 维度</text>
|
||||
<text class="app-subtitle">从 5 个维度系统理解人工智能</text>
|
||||
|
||||
<!-- Tab 切换 — 两种模式共用 -->
|
||||
<view class="tab-switch">
|
||||
<text class="tab-item" :class="{ active: activeTab === 'login' }" @click="activeTab = 'login'">登录</text>
|
||||
<text class="tab-item" :class="{ active: activeTab === 'register' }" @click="activeTab = 'register'">注册</text>
|
||||
@@ -23,17 +20,31 @@
|
||||
<!-- 登录表单 -->
|
||||
<view class="form-card" v-if="activeTab === 'login'">
|
||||
<view class="form-item">
|
||||
<text class="form-label">用户名</text>
|
||||
<input class="form-input" v-model="loginForm.username" placeholder="请输入用户名" />
|
||||
<text class="form-label">邮箱 / 用户名</text>
|
||||
<input class="form-input" v-model="loginForm.email" placeholder="请输入邮箱或用户名" @input="loginForm.email = $event.detail.value.trim()" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">密码</text>
|
||||
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
|
||||
</view>
|
||||
<view class="submit-btn" :class="{ loading: loading }" @click="handleLogin">
|
||||
|
||||
<!-- 登录协议勾选 -->
|
||||
<view class="agreement-row">
|
||||
<view class="agreement-checkbox" :class="{ checked: loginAgreed }" @click="loginAgreed = !loginAgreed">
|
||||
<text v-if="loginAgreed">✓</text>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
登录即表示同意
|
||||
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
||||
和
|
||||
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" :class="{ loading: loading, disabled: !loginAgreed }" @click="handleLogin">
|
||||
<text>{{ loading ? '登录中...' : '登录' }}</text>
|
||||
</view>
|
||||
<!-- 微信小程序一键登录入口 -->
|
||||
<!-- 微信小程序一键登录 -->
|
||||
<view class="wx-login-row" v-if="isMiniApp">
|
||||
<view class="wx-login-btn" @click="wechatLogin">
|
||||
<text class="wx-icon">💬</text>
|
||||
@@ -45,23 +56,47 @@
|
||||
<!-- 注册表单 -->
|
||||
<view class="form-card" v-if="activeTab === 'register'">
|
||||
<view class="form-item">
|
||||
<text class="form-label">用户名</text>
|
||||
<input class="form-input" v-model="registerForm.username" placeholder="3-20 位字符" />
|
||||
<text class="form-label">邮箱 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
||||
<input class="form-input" v-model="registerForm.email" type="email" placeholder="请输入邮箱地址" @input="registerForm.email = $event.detail.value.trim(); verifyCodeSent = false" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">昵称</text>
|
||||
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称(选填)" />
|
||||
<text class="form-label">昵称 <text style="color:var(--text-muted);font-size:11px;">(选填)</text></text>
|
||||
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称,留空自动生成" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">密码</text>
|
||||
<text class="form-label">密码 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
||||
<input class="form-input" v-model="registerForm.password" type="password" placeholder="至少 6 位" />
|
||||
</view>
|
||||
<view class="submit-btn" :class="{ loading: loading }" @click="handleRegister">
|
||||
|
||||
<!-- 邮箱验证码 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">验证码 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
||||
<view class="verify-row">
|
||||
<input class="form-input verify-input" v-model="registerForm.verifyCode" type="text" maxlength="6" placeholder="输入6位验证码" />
|
||||
<view class="send-code-btn" :class="{ disabled: codeSending || codeCountdown > 0 }" @click="handleSendCode">
|
||||
<text>{{ codeCountdown > 0 ? codeCountdown + 's' : (codeSending ? '发送中...' : '获取验证码') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户协议勾选 -->
|
||||
<view class="agreement-row">
|
||||
<view class="agreement-checkbox" :class="{ checked: agreed }" @click="agreed = !agreed">
|
||||
<text v-if="agreed">✓</text>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
我已阅读并同意
|
||||
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
||||
和
|
||||
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" :class="{ loading: loading, disabled: !agreed }" @click="handleRegister">
|
||||
<text>{{ loading ? '注册中...' : '注册' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<view class="footer-info">
|
||||
<text>© 2026 宇之然 · AI 维度</text>
|
||||
</view>
|
||||
@@ -73,76 +108,107 @@
|
||||
import { ref } from 'vue'
|
||||
import { useUserStore } from '@/stores/index'
|
||||
|
||||
// 检测是否为微信小程序环境
|
||||
const isMiniApp = ref(false)
|
||||
const platform = uni.getSystemInfoSync().platform
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
// 小程序环境:platform 为 ios/android/devtools,且有 brand/version 等字段
|
||||
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
||||
// 有 weixin 特有字段即为小程序
|
||||
isMiniApp.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
// H5 环境
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const agreed = ref(false)
|
||||
const loginAgreed = ref(false)
|
||||
const codeSending = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
const verifyCodeSent = ref(false)
|
||||
let countdownTimer = null
|
||||
const userStore = useUserStore()
|
||||
|
||||
// H5 表单
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
const registerForm = ref({ username: '', password: '', nickName: '' })
|
||||
const loginForm = ref({ email: '', password: '' })
|
||||
const registerForm = ref({ email: '', password: '', nickName: '', verifyCode: '' })
|
||||
|
||||
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
||||
|
||||
/** 发送邮箱验证码 */
|
||||
async function handleSendCode() {
|
||||
const email = registerForm.value.email
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
uni.showToast({ title: '请先输入正确的邮箱', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (codeSending.value || codeCountdown.value > 0) return
|
||||
|
||||
codeSending.value = true
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/api/auth/user/send-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error?.message || data.message || '发送失败')
|
||||
}
|
||||
verifyCodeSent.value = true
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||||
// 开始倒计时
|
||||
codeCountdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '发送失败,请稍后重试', icon: 'none' })
|
||||
} finally {
|
||||
codeSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*/
|
||||
const wechatLogin = async () => {
|
||||
try {
|
||||
const loginRes = await new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
uni.login({ success: resolve, fail: reject })
|
||||
})
|
||||
|
||||
if (!loginRes.code) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
await userStore.login(loginRes.code, {})
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
console.error('微信登录失败:', err)
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 用户名密码登录
|
||||
*/
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
if (!loginAgreed.value) {
|
||||
uni.showToast({ title: '请阅读并同意用户协议和隐私政策', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!loginForm.value.email || !loginForm.value.password) {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.h5Login(loginForm.value.username, loginForm.value.password)
|
||||
// 支持邮箱或用户名登录
|
||||
const identifier = loginForm.value.email
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier)
|
||||
const loginData = isEmail
|
||||
? { email: identifier, password: loginForm.value.password }
|
||||
: { username: identifier, password: loginForm.value.password }
|
||||
await userStore.h5Login(loginData)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
@@ -150,36 +216,55 @@ const handleLogin = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 注册
|
||||
*/
|
||||
const handleRegister = async () => {
|
||||
if (!registerForm.value.username || !registerForm.value.password) {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请阅读并同意用户协议和隐私政策', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!registerForm.value.email) {
|
||||
uni.showToast({ title: '请输入邮箱', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.value.email)) {
|
||||
uni.showToast({ title: '邮箱格式不正确', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!registerForm.value.password) {
|
||||
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (registerForm.value.password.length < 6) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!registerForm.value.verifyCode) {
|
||||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.h5Register(
|
||||
registerForm.value.username,
|
||||
registerForm.value.email,
|
||||
registerForm.value.password,
|
||||
registerForm.value.nickName || ''
|
||||
registerForm.value.nickName || '',
|
||||
registerForm.value.verifyCode
|
||||
)
|
||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '注册失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAgreement(type) {
|
||||
const pages = {
|
||||
'terms': '/pages/agreement/agreement?type=terms',
|
||||
'privacy': '/pages/agreement/agreement?type=privacy'
|
||||
}
|
||||
uni.navigateTo({ url: pages[type] || pages.terms })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -193,7 +278,6 @@ const handleRegister = async () => {
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
|
||||
// 背景装饰光球
|
||||
.bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
@@ -201,199 +285,123 @@ const handleRegister = async () => {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orb-1 {
|
||||
width: 200px; height: 200px;
|
||||
top: -50px; left: -50px;
|
||||
background: var(--dim1-color);
|
||||
}
|
||||
.orb-2 {
|
||||
width: 160px; height: 160px;
|
||||
top: 30%; right: -40px;
|
||||
background: var(--dim3-color);
|
||||
}
|
||||
.orb-3 {
|
||||
width: 180px; height: 180px;
|
||||
bottom: -60px; left: 30%;
|
||||
background: var(--dim5-color);
|
||||
}
|
||||
.orb-1 { width: 200px; height: 200px; top: -50px; left: -50px; background: var(--dim1-color); }
|
||||
.orb-2 { width: 160px; height: 160px; top: 30%; right: -40px; background: var(--dim3-color); }
|
||||
.orb-3 { width: 180px; height: 180px; bottom: -60px; left: 30%; background: var(--dim5-color); }
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; max-width: 360px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
|
||||
// Logo
|
||||
.logo-wrap {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
position: relative; width: 72px; height: 72px; margin-bottom: 16px;
|
||||
.logo-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 18px;
|
||||
position: absolute; inset: 0; border-radius: 18px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
box-shadow: 0 8px 32px rgba(124, 77, 255, 0.2);
|
||||
}
|
||||
.logo-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 700; color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.app-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.app-title { font-size: 24px; font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
|
||||
.app-subtitle { font-size: 13px; color: var(--text-secondary); margin-bottom: 32px; }
|
||||
|
||||
// Tab 切换
|
||||
.tab-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
|
||||
display: flex; width: 100%; margin-bottom: 20px; border-bottom: 1px solid var(--glass-border);
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
color: var(--color-brand);
|
||||
border-bottom-color: var(--color-brand);
|
||||
}
|
||||
flex: 1; text-align: center; padding: 10px 0;
|
||||
color: var(--text-secondary); font-size: 15px; font-weight: 500;
|
||||
border-bottom: 2px solid transparent; transition: all 0.3s;
|
||||
&.active { color: var(--color-brand); border-bottom-color: var(--color-brand); }
|
||||
}
|
||||
}
|
||||
|
||||
// 表单卡片
|
||||
.form-card {
|
||||
width: 100%;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
background: var(--glass-bg); border: 1px solid var(--glass-border);
|
||||
border-radius: 16px; padding: 24px 20px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-item { margin-bottom: 16px;
|
||||
.form-label { display: block; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 500; }
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
width: 100%; height: 44px;
|
||||
background: var(--bg-secondary); border: 1px solid var(--glass-border);
|
||||
border-radius: 10px; padding: 0 14px;
|
||||
color: var(--text-primary); font-size: 14px; box-sizing: border-box; outline: none;
|
||||
&::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
}
|
||||
|
||||
// 提交按钮
|
||||
// 验证码行
|
||||
.verify-row {
|
||||
display: flex; gap: 10px;
|
||||
.verify-input { flex: 1; }
|
||||
.send-code-btn {
|
||||
flex-shrink: 0; height: 44px; padding: 0 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--dim3-color));
|
||||
color: white; border-radius: 10px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
&.disabled { opacity: 0.5; pointer-events: none; }
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
}
|
||||
|
||||
// 用户协议勾选
|
||||
.agreement-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.agreement-checkbox {
|
||||
width: 20px; height: 20px;
|
||||
border: 2px solid var(--glass-border);
|
||||
border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
text { font-size: 12px; color: white; font-weight: 700; display: none; }
|
||||
&.checked {
|
||||
background: var(--color-brand); border-color: var(--color-brand);
|
||||
text { display: block; }
|
||||
}
|
||||
}
|
||||
.agreement-text { font-size: 12px; color: var(--text-tertiary); line-height: 1.5; }
|
||||
.agreement-link { color: var(--color-brand); text-decoration: underline; }
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%; height: 46px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--dim3-color));
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
color: white; border-radius: 10px; font-size: 15px; font-weight: 600;
|
||||
margin-top: 8px; cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(124, 77, 255, 0.25);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
&:active { opacity: 0.8; }
|
||||
&.loading { opacity: 0.7; }
|
||||
&.disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
|
||||
// 微信一键登录
|
||||
.wx-login-row {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--glass-border);
|
||||
.wx-login-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.wx-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
width: 100%; height: 46px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56); color: white;
|
||||
border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3); transition: opacity 0.3s;
|
||||
&:active { opacity: 0.8; }
|
||||
.wx-icon { font-size: 18px; }
|
||||
}
|
||||
}
|
||||
|
||||
// 底部信息
|
||||
.footer-info {
|
||||
margin-top: 40px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.footer-info { margin-top: 40px; font-size: 11px; color: var(--text-muted); }
|
||||
}
|
||||
</style>
|
||||
@@ -28,16 +28,16 @@ export const useUserStore = defineStore('user', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
async function h5Login(username, password) {
|
||||
const data = await authApi.h5Login(username, password)
|
||||
async function h5Login(loginData) {
|
||||
const data = await authApi.h5Login(loginData)
|
||||
token.value = data.token
|
||||
userInfo.value = data.userInfo
|
||||
uni.setStorageSync('token', data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
async function h5Register(username, password, nickName) {
|
||||
const data = await authApi.register(username, password, nickName)
|
||||
async function h5Register(email, password, nickName, verifyCode) {
|
||||
const data = await authApi.register(email, password, nickName, verifyCode)
|
||||
token.value = data.token
|
||||
userInfo.value = data.userInfo
|
||||
uni.setStorageSync('token', data.token)
|
||||
|
||||
@@ -50,12 +50,12 @@ export const authApi = {
|
||||
request('POST', '/api/auth/login', { code, userInfo }),
|
||||
|
||||
/** H5 密码登录 */
|
||||
h5Login: (username, password) =>
|
||||
request('POST', '/api/auth/user/login', { username, password }),
|
||||
h5Login: (loginData) =>
|
||||
request('POST', '/api/auth/user/login', loginData),
|
||||
|
||||
/** H5 注册 */
|
||||
register: (username, password, nickName) =>
|
||||
request('POST', '/api/auth/user/register', { username, password, nickName }),
|
||||
register: (email, password, nickName, verifyCode) =>
|
||||
request('POST', '/api/auth/user/register', { email, password, nickName, verifyCode }),
|
||||
|
||||
/** 获取当前用户 */
|
||||
me: (token) =>
|
||||
|
||||
Generated
+14
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"name": "ai-dimension-server",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"name": "ai-dimension-server",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
@@ -24,6 +24,7 @@
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4250,6 +4251,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
|
||||
+7
-1
@@ -8,7 +8,12 @@
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": ["wechat", "miniprogram", "ai", "education"],
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"miniprogram",
|
||||
"ai",
|
||||
"education"
|
||||
],
|
||||
"author": "宇之然团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -27,6 +32,7 @@
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -53,13 +53,19 @@ const UserSchema = new Schema({
|
||||
},
|
||||
|
||||
// H5环境用户名密码登录
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
select: false
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
const VerificationCodeSchema = new Schema({
|
||||
email: { type: String, required: true, index: true },
|
||||
code: { type: String, required: true },
|
||||
expiresAt: { type: Date, required: true, index: true },
|
||||
used: { type: Boolean, default: false },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
// 自动过期 TTL 索引(MongoDB 自动删除过期文档)
|
||||
VerificationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
|
||||
|
||||
module.exports = mongoose.model('VerificationCode', VerificationCodeSchema)
|
||||
@@ -9,6 +9,7 @@ const AIModel = require('./AIModel')
|
||||
const LoginLog = require('./LoginLog')
|
||||
const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
const VerificationCode = require('./VerificationCode')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
@@ -21,5 +22,6 @@ module.exports = {
|
||||
AIModel,
|
||||
LoginLog,
|
||||
Feedback,
|
||||
Trend
|
||||
Trend,
|
||||
VerificationCode
|
||||
}
|
||||
|
||||
+119
-16
@@ -1,10 +1,11 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { User, Admin, AdminLog, LoginLog } = require('../models')
|
||||
const { User, Admin, AdminLog, LoginLog, VerificationCode } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const logger = require('../utils/logger')
|
||||
const { sendVerificationCode } = require('../utils/mail')
|
||||
const axios = require('axios')
|
||||
|
||||
/**
|
||||
@@ -312,10 +313,17 @@ router.post('/admin/login', async (req, res) => {
|
||||
*/
|
||||
router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
const { username, email, password } = req.body
|
||||
// 支持邮箱或用户名登录
|
||||
const identifier = email || username
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({ username }).select('+password')
|
||||
const user = await User.findOne({
|
||||
$or: [
|
||||
{ email: identifier },
|
||||
{ username: identifier }
|
||||
]
|
||||
}).select('+password')
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
@@ -349,28 +357,123 @@ router.post('/user/login', async (req, res) => {
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
* POST /api/auth/user/send-code
|
||||
*/
|
||||
router.post('/user/send-code', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return ApiResponse.error(res, '邮箱格式不正确', 400)
|
||||
}
|
||||
|
||||
// 检查邮箱是否已被注册
|
||||
const existing = await User.findOne({ email })
|
||||
if (existing) {
|
||||
return ApiResponse.error(res, '该邮箱已被注册', 400)
|
||||
}
|
||||
|
||||
// 检查发送频率(1分钟内只能发一次)
|
||||
const recent = await VerificationCode.findOne({
|
||||
email,
|
||||
createdAt: { $gte: new Date(Date.now() - 60000) }
|
||||
})
|
||||
if (recent) {
|
||||
return ApiResponse.error(res, '发送过于频繁,请 1 分钟后再试', 429)
|
||||
}
|
||||
|
||||
// 生成6位随机验证码
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
|
||||
// 10分钟过期
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
|
||||
|
||||
// 使之前的验证码失效
|
||||
await VerificationCode.updateMany({ email, used: false }, { $set: { used: true } })
|
||||
|
||||
// 保存新验证码
|
||||
await VerificationCode.create({ email, code, expiresAt })
|
||||
|
||||
// 发送邮件
|
||||
const result = await sendVerificationCode(email, code)
|
||||
|
||||
if (result.success) {
|
||||
return ApiResponse.success(res, {
|
||||
devCode: result.devCode || undefined
|
||||
}, '验证码已发送')
|
||||
} else {
|
||||
return ApiResponse.error(res, '验证码发送失败,请稍后重试', 500)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('发送验证码失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册(需邮箱验证码)
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
router.post('/user/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, nickName, avatarUrl } = req.body
|
||||
const { username, email, password, nickName, avatarUrl, verifyCode } = req.body
|
||||
const identifier = email || username
|
||||
|
||||
if (!identifier) {
|
||||
return ApiResponse.error(res, '请输入邮箱或用户名', 400)
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return ApiResponse.error(res, '邮箱为必填项', 400)
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
if (!verifyCode) {
|
||||
return ApiResponse.error(res, '请输入邮箱验证码', 400)
|
||||
}
|
||||
const validCode = await VerificationCode.findOne({
|
||||
email,
|
||||
code: verifyCode,
|
||||
used: false,
|
||||
expiresAt: { $gt: new Date() }
|
||||
})
|
||||
if (!validCode) {
|
||||
return ApiResponse.error(res, '验证码无效或已过期', 400)
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if (email) {
|
||||
const existingEmail = await User.findOne({ email })
|
||||
if (existingEmail) {
|
||||
return ApiResponse.error(res, '该邮箱已被注册', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
return ApiResponse.error(res, '用户名已存在', 400)
|
||||
if (username) {
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
return ApiResponse.error(res, '用户名已存在', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 标记验证码为已使用
|
||||
validCode.used = true
|
||||
await validCode.save()
|
||||
|
||||
// 自动生成用户名(若未提供)
|
||||
const finalUsername = username || email.split('@')[0]
|
||||
|
||||
// 创建新用户
|
||||
const user = new User({
|
||||
username,
|
||||
username: finalUsername,
|
||||
email: email || '',
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
avatarUrl: avatarUrl || '',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const nodemailer = require('nodemailer')
|
||||
const logger = require('./logger')
|
||||
|
||||
// SMTP 配置(优先从环境变量读取,方便部署时配置)
|
||||
const SMTP_HOST = process.env.SMTP_HOST || ''
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '465')
|
||||
const SMTP_USER = process.env.SMTP_USER || ''
|
||||
const SMTP_PASS = process.env.SMTP_PASS || ''
|
||||
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER
|
||||
|
||||
let transporter = null
|
||||
|
||||
function getTransporter() {
|
||||
if (transporter) return transporter
|
||||
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) {
|
||||
logger.warn('[mail] SMTP 未配置,邮件发送功能不可用')
|
||||
return null
|
||||
}
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS }
|
||||
})
|
||||
return transporter
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} code - 6位验证码
|
||||
*/
|
||||
async function sendVerificationCode(to, code) {
|
||||
const transport = getTransporter()
|
||||
if (!transport) {
|
||||
// 开发环境:直接打印验证码
|
||||
logger.info(`[mail-dev] 验证码 ${code} -> ${to} (SMTP 未配置,请设置 SMTP_HOST/SMTP_USER/SMTP_PASS)`)
|
||||
return { success: true, devCode: code }
|
||||
}
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: `"AI维度" <${SMTP_FROM}>`,
|
||||
to,
|
||||
subject: '【AI维度】邮箱验证码',
|
||||
html: `
|
||||
<div style="max-width:480px;margin:0 auto;padding:24px;font-family:sans-serif;">
|
||||
<h2 style="color:#6366f1;">AI 维度 - 邮箱验证</h2>
|
||||
<p>您好!</p>
|
||||
<p>您的验证码为:</p>
|
||||
<div style="font-size:32px;font-weight:700;color:#6366f1;letter-spacing:8px;text-align:center;padding:20px;background:#f5f5f7;border-radius:12px;margin:16px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color:#666;">验证码有效期为 10 分钟,请勿泄露给他人。</p>
|
||||
<p style="color:#999;font-size:12px;">如果这不是您的操作,请忽略此邮件。</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:20px 0;">
|
||||
<p style="color:#999;font-size:12px;text-align:center;">宇之然 AI 维度 · 北京宇之然科技中心</p>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
logger.info(`[mail] 验证码邮件发送成功 -> ${to}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`[mail] 发送验证码失败 -> ${to}:`, error.message)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendVerificationCode }
|
||||
Reference in New Issue
Block a user