feat: phone validate
This commit is contained in:
14
frontend/wechat/src/api/authApi.js
Normal file
14
frontend/wechat/src/api/authApi.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import client from "./client";
|
||||
|
||||
export const authApi = {
|
||||
sendPhoneSms(phone) {
|
||||
return client.post("/auth/phone", { phone: String(phone || "").trim() });
|
||||
},
|
||||
validateSmsCode(phone, code) {
|
||||
return client.post("/auth/validate", {
|
||||
phone: String(phone || "").trim(),
|
||||
code: String(code || "").trim(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function getCurrentPathWithQueryHash() {
|
||||
const { pathname, search, hash } = window.location;
|
||||
return `${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
// Create axios instance with default config
|
||||
const client = axios.create({
|
||||
baseURL: '/v1',
|
||||
@@ -29,13 +34,19 @@ client.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
const noAuthRedirect = Boolean(error?.config?.meta?.noAuthRedirect);
|
||||
// Handle HTTP errors here
|
||||
if (error.response) {
|
||||
// Handle 401 Unauthorized error
|
||||
if (error.response.status === 401) {
|
||||
const redirectUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `/v1/auth/wechat?redirect=${redirectUrl}`;
|
||||
return;
|
||||
if (noAuthRedirect) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const redirectPath = encodeURIComponent(getCurrentPathWithQueryHash());
|
||||
sessionStorage.setItem('post_auth_redirect', getCurrentPathWithQueryHash());
|
||||
sessionStorage.removeItem('phone_verified');
|
||||
window.location.href = `/verify-phone?redirect=${redirectPath}`;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// Server responded with error status
|
||||
console.error('API Error:', error.response.status, error.response.data);
|
||||
|
||||
@@ -3,6 +3,7 @@ import client from './client';
|
||||
export const postApi = {
|
||||
list({ page = 1, limit = 10, keyword = '' } = {}) {
|
||||
return client.get('/posts', {
|
||||
meta: { noAuthRedirect: true },
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
@@ -12,10 +13,10 @@ export const postApi = {
|
||||
},
|
||||
|
||||
play(id) {
|
||||
return client.get(`/posts/${id}/play`);
|
||||
return client.get(`/posts/${id}/play`, { meta: { noAuthRedirect: true } });
|
||||
},
|
||||
show(id) {
|
||||
return client.get(`/posts/${id}/show`);
|
||||
return client.get(`/posts/${id}/show`, { meta: { noAuthRedirect: true } });
|
||||
},
|
||||
mine({ page = 1, limit = 10 } = {}) {
|
||||
return client.get('/posts/mine', {
|
||||
@@ -28,4 +29,4 @@ export const postApi = {
|
||||
buy(id) {
|
||||
return client.post(`/posts/${id}/buy`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref(0)
|
||||
const isPhoneVerified = () => sessionStorage.getItem('phone_verified') === '1'
|
||||
|
||||
const tabs = [
|
||||
{ label: '列表', route: '/', icon: AiOutlineHome },
|
||||
@@ -13,6 +14,11 @@ const tabs = [
|
||||
]
|
||||
|
||||
const switchTab = (index, route) => {
|
||||
if ((route === '/purchased' || route === '/profile') && !isPhoneVerified()) {
|
||||
const redirect = encodeURIComponent(route)
|
||||
router.replace(`/verify-phone?redirect=${redirect}`)
|
||||
return
|
||||
}
|
||||
activeTab.value = index
|
||||
router.replace(route)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ const routes = [
|
||||
path: '/posts/:id',
|
||||
name: 'article-detail',
|
||||
component: () => import('@/views/ArticleDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/verify-phone',
|
||||
name: 'verify-phone',
|
||||
component: () => import('@/views/VerifyPhone.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -87,6 +87,11 @@ const updateMediaSource = async () => {
|
||||
};
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (sessionStorage.getItem('phone_verified') !== '1') {
|
||||
const redirect = encodeURIComponent(router.currentRoute.value.fullPath);
|
||||
router.replace(`/verify-phone?redirect=${redirect}`);
|
||||
return;
|
||||
}
|
||||
// confirm
|
||||
if (!confirm("确认购买该曲谱?")) {
|
||||
return;
|
||||
|
||||
123
frontend/wechat/src/views/VerifyPhone.vue
Normal file
123
frontend/wechat/src/views/VerifyPhone.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { authApi } from "@/api/authApi";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const phone = ref("");
|
||||
const code = ref("");
|
||||
const sending = ref(false);
|
||||
const verifying = ref(false);
|
||||
const countdown = ref(0);
|
||||
let countdownTimer = null;
|
||||
|
||||
const canSend = computed(() => !sending.value && countdown.value <= 0 && phone.value.trim().length >= 6);
|
||||
const canVerify = computed(() => !verifying.value && phone.value.trim() !== "" && code.value.trim() !== "");
|
||||
|
||||
const startCountdown = (seconds = 60) => {
|
||||
countdown.value = seconds;
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
await authApi.sendPhoneSms(phone.value);
|
||||
startCountdown(60);
|
||||
alert("验证码已发送");
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
alert(error?.response?.data?.message || "发送验证码失败");
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRedirect = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const decoded = decodeURIComponent(String(value));
|
||||
if (decoded.startsWith("/")) return decoded;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!canVerify.value) return;
|
||||
verifying.value = true;
|
||||
try {
|
||||
await authApi.validateSmsCode(phone.value, code.value);
|
||||
sessionStorage.setItem("phone_verified", "1");
|
||||
const redirect =
|
||||
normalizeRedirect(route.query.redirect) ||
|
||||
normalizeRedirect(sessionStorage.getItem("post_auth_redirect")) ||
|
||||
"/";
|
||||
sessionStorage.removeItem("post_auth_redirect");
|
||||
router.replace(redirect);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "验证失败");
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const redirect = normalizeRedirect(route.query.redirect);
|
||||
if (redirect) sessionStorage.setItem("post_auth_redirect", redirect);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-[420px] bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900">手机号验证</h1>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
未认证用户需要验证手机号后才能访问「已购买」「我的」并进行购买操作。
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
|
||||
<input v-model="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号"
|
||||
class="w-full rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入短信验证码"
|
||||
class="flex-1 rounded-xl border border-gray-200 px-4 py-3 outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-300" />
|
||||
<button :disabled="!canSend" @click="handleSend"
|
||||
class="whitespace-nowrap rounded-xl px-4 py-3 text-sm font-medium border transition-colors"
|
||||
:class="canSend ? 'bg-primary-600 text-white border-primary-600 hover:bg-primary-700 active:bg-primary-800' : 'bg-gray-100 text-gray-400 border-gray-200'">
|
||||
<span v-if="countdown > 0">{{ countdown }}s</span>
|
||||
<span v-else>发送验证码</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button :disabled="!canVerify" @click="handleVerify"
|
||||
class="w-full rounded-xl px-4 py-3 font-semibold transition-colors"
|
||||
:class="canVerify ? 'bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800' : 'bg-gray-100 text-gray-400'">
|
||||
{{ verifying ? "验证中..." : "验证并继续" }}
|
||||
</button>
|
||||
|
||||
<button class="w-full text-sm text-gray-500 hover:text-gray-700" @click="router.replace('/')">
|
||||
暂不验证,返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because one or more lines are too long
@@ -4,53 +4,61 @@ import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
import compression from 'vite-plugin-compression';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
compression({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240,
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
}),
|
||||
visualizer({
|
||||
open: false,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['mp.jdwan.com'],
|
||||
port: 3001,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/v1': 'http://localhost:8088',
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourceMap: true,
|
||||
minify: 'terser',
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'player-vendor': ['xgplayer', 'xgplayer-mp4'],
|
||||
}
|
||||
export default defineConfig(() => {
|
||||
const backendUrl = process.env.VITE_BACKEND_URL || 'http://127.0.0.1:8088';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
compression({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240,
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
}),
|
||||
visualizer({
|
||||
open: false,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['mp.jdwan.com'],
|
||||
port: 3001,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourceMap: true,
|
||||
minify: 'terser',
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'player-vendor': ['xgplayer', 'xgplayer-mp4'],
|
||||
}
|
||||
}
|
||||
},
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user