fix:优化页面
This commit is contained in:
@@ -10,7 +10,10 @@ const showAppShell = computed(() => !route.path.startsWith('/auth'));
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||||
<div class="max-w-sm mx-auto min-h-screen relative pb-24 w-full">
|
<div
|
||||||
|
class="max-w-sm mx-auto min-h-screen relative w-full"
|
||||||
|
:class="showAppShell ? 'pb-24' : 'pb-0'"
|
||||||
|
>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<QuickAddButton v-if="showAppShell" />
|
<QuickAddButton v-if="showAppShell" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const REMEMBER_KEY = 'ai-bill/remember-email';
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: 'user@example.com',
|
email: '',
|
||||||
password: 'Password123!'
|
password: ''
|
||||||
});
|
});
|
||||||
|
const remember = ref(true);
|
||||||
|
|
||||||
|
// 读取记住的邮箱
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem(REMEMBER_KEY);
|
||||||
|
if (saved) form.email = saved;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const successMessage = ref('');
|
const successMessage = ref('');
|
||||||
@@ -28,6 +36,11 @@ const submit = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post('/auth/login', form);
|
const { data } = await apiClient.post('/auth/login', form);
|
||||||
authStore.setSession(data.tokens, data.user);
|
authStore.setSession(data.tokens, data.user);
|
||||||
|
if (remember.value && typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(REMEMBER_KEY, form.email);
|
||||||
|
} else if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(REMEMBER_KEY);
|
||||||
|
}
|
||||||
successMessage.value = '登录成功,正在跳转...';
|
successMessage.value = '登录成功,正在跳转...';
|
||||||
await router.replace(redirectTarget.value);
|
await router.replace(redirectTarget.value);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -71,6 +84,10 @@ const enterDemoMode = () => {
|
|||||||
密码
|
密码
|
||||||
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center space-x-2 text-xs text-gray-600">
|
||||||
|
<input v-model="remember" type="checkbox" class="w-4 h-4 rounded border-gray-300" />
|
||||||
|
<span>记住账号</span>
|
||||||
|
</label>
|
||||||
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
|
||||||
<p v-if="successMessage" class="text-sm text-emerald-500">{{ successMessage }}</p>
|
<p v-if="successMessage" class="text-sm text-emerald-500">{{ successMessage }}</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -196,10 +196,10 @@ const refreshTransactions = async () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showSheet"
|
v-if="showSheet"
|
||||||
class="fixed inset-0 bg-black/40 flex items-end justify-center"
|
class="fixed inset-0 z-50 bg-black/40 flex items-end justify-center"
|
||||||
@click.self="showSheet = false"
|
@click.self="showSheet = false"
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-sm bg-white rounded-t-3xl p-6 space-y-4">
|
<div class="w-full max-w-sm bg-white rounded-t-3xl p-6 space-y-4 pb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">快速记一笔</h2>
|
<h2 class="text-lg font-semibold text-gray-900">快速记一笔</h2>
|
||||||
<button @click="showSheet = false">
|
<button @click="showSheet = false">
|
||||||
|
|||||||
@@ -25,11 +25,22 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
console.error('API error', error.response.status, error.response.data);
|
console.error('API error', error.response.status, error.response.data);
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
// 避免刷新递归:标记本次请求是否已重试
|
||||||
|
const original = error.config as any;
|
||||||
|
if (!original._retry) {
|
||||||
|
original._retry = true;
|
||||||
|
const ok = await authStore.refresh();
|
||||||
|
if (ok && authStore.accessToken) {
|
||||||
|
original.headers = original.headers || {};
|
||||||
|
original.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||||
|
return apiClient(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
authStore.clearSession();
|
authStore.clearSession();
|
||||||
const current = router.currentRoute.value;
|
const current = router.currentRoute.value;
|
||||||
if (!current.path.startsWith('/auth')) {
|
if (!current.path.startsWith('/auth')) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface AuthState {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
profile?: UserProfile;
|
profile?: UserProfile;
|
||||||
|
expiresIn?: number; // epoch seconds
|
||||||
|
refreshExpiresIn?: number; // epoch seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
@@ -27,11 +29,16 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
isAuthenticated: (state) => state.status === 'authenticated'
|
isAuthenticated: (state) => state.status === 'authenticated'
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
setSession(tokens: { accessToken: string; refreshToken: string }, profile: UserProfile) {
|
setSession(
|
||||||
|
tokens: { accessToken: string; refreshToken: string; expiresIn?: number; refreshExpiresIn?: number },
|
||||||
|
profile: UserProfile
|
||||||
|
) {
|
||||||
this.status = 'authenticated';
|
this.status = 'authenticated';
|
||||||
this.accessToken = tokens.accessToken;
|
this.accessToken = tokens.accessToken;
|
||||||
this.refreshToken = tokens.refreshToken;
|
this.refreshToken = tokens.refreshToken;
|
||||||
this.profile = profile;
|
this.profile = profile;
|
||||||
|
this.expiresIn = tokens.expiresIn;
|
||||||
|
this.refreshExpiresIn = tokens.refreshExpiresIn;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
@@ -39,20 +46,59 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
profile
|
profile,
|
||||||
|
expiresIn: tokens.expiresIn,
|
||||||
|
refreshExpiresIn: tokens.refreshExpiresIn
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.scheduleRefresh();
|
||||||
},
|
},
|
||||||
clearSession() {
|
clearSession() {
|
||||||
this.status = 'guest';
|
this.status = 'guest';
|
||||||
this.accessToken = undefined;
|
this.accessToken = undefined;
|
||||||
this.refreshToken = undefined;
|
this.refreshToken = undefined;
|
||||||
this.profile = undefined;
|
this.profile = undefined;
|
||||||
|
this.expiresIn = undefined;
|
||||||
|
this.refreshExpiresIn = undefined;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refresh() {
|
||||||
|
if (!this.refreshToken) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch((import.meta as any).env.VITE_API_BASE_URL + '/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken: this.refreshToken })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('refresh failed');
|
||||||
|
const data = await res.json();
|
||||||
|
this.setSession(
|
||||||
|
{
|
||||||
|
accessToken: data.tokens.accessToken,
|
||||||
|
refreshToken: data.tokens.refreshToken,
|
||||||
|
expiresIn: data.tokens.expiresIn,
|
||||||
|
refreshExpiresIn: data.tokens.refreshExpiresIn
|
||||||
|
},
|
||||||
|
data.user
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this.clearSession();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scheduleRefresh() {
|
||||||
|
if (!this.expiresIn) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsLeft = this.expiresIn - nowSec;
|
||||||
|
const refreshInMs = Math.max((secondsLeft - 60) * 1000, 5_000); // 提前 60 秒刷新
|
||||||
|
setTimeout(() => {
|
||||||
|
void this.refresh();
|
||||||
|
}, refreshInMs);
|
||||||
|
},
|
||||||
initialize() {
|
initialize() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -62,12 +108,22 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(raw) as { status: AuthStatus; accessToken: string; refreshToken: string; profile: UserProfile };
|
const stored = JSON.parse(raw) as {
|
||||||
|
status: AuthStatus;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
profile: UserProfile;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshExpiresIn?: number;
|
||||||
|
};
|
||||||
if (stored.status === 'authenticated' && stored.accessToken && stored.refreshToken && stored.profile) {
|
if (stored.status === 'authenticated' && stored.accessToken && stored.refreshToken && stored.profile) {
|
||||||
this.status = stored.status;
|
this.status = stored.status;
|
||||||
this.accessToken = stored.accessToken;
|
this.accessToken = stored.accessToken;
|
||||||
this.refreshToken = stored.refreshToken;
|
this.refreshToken = stored.refreshToken;
|
||||||
this.profile = stored.profile;
|
this.profile = stored.profile;
|
||||||
|
this.expiresIn = stored.expiresIn;
|
||||||
|
this.refreshExpiresIn = stored.refreshExpiresIn;
|
||||||
|
this.scheduleRefresh();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to restore auth session', error);
|
console.warn('Failed to restore auth session', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user