feat:添加登录功能
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
package com.bill.ai;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.bill.ai.notification.NotificationPermissionPlugin;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(NotificationPermissionPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.bill.ai.notification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.getcapacitor.annotation.PluginMethod;
|
||||
|
||||
@CapacitorPlugin(name = "NotificationPermissions", permissions = {
|
||||
@Permission(alias = "notifications", strings = {"android.permission.POST_NOTIFICATIONS"})
|
||||
})
|
||||
public class NotificationPermissionPlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void checkStatus(@NonNull PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("granted", isNotificationListenerEnabled(getContext()));
|
||||
result.put("postNotificationsGranted", isPostNotificationsGranted());
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void requestAccess(@NonNull PluginCall call) {
|
||||
boolean granted = isNotificationListenerEnabled(getContext());
|
||||
if (!granted) {
|
||||
openNotificationListenerSettings();
|
||||
}
|
||||
JSObject result = new JSObject();
|
||||
result.put("opened", true);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openSettings(@NonNull PluginCall call) {
|
||||
openNotificationListenerSettings();
|
||||
JSObject result = new JSObject();
|
||||
result.put("opened", true);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void requestPostNotifications(@NonNull PluginCall call) {
|
||||
if (isPostNotificationsGranted()) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("granted", true);
|
||||
call.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
requestPermissionForAlias("notifications", call, "handlePostNotificationPermission");
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void handlePostNotificationPermission(PluginCall call) {
|
||||
boolean granted = isPostNotificationsGranted();
|
||||
JSObject result = new JSObject();
|
||||
result.put("granted", granted);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private boolean isNotificationListenerEnabled(Context context) {
|
||||
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.getPackageName());
|
||||
}
|
||||
|
||||
private boolean isPostNotificationsGranted() {
|
||||
return NotificationManagerCompat.from(getContext()).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
private void openNotificationListenerSettings() {
|
||||
Context context = getContext();
|
||||
Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^7.4.4",
|
||||
"@capacitor/app": "^7.1.0",
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@capacitor/core": "^7.4.4",
|
||||
"@tanstack/vue-query": "^5",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { sampleBudgets } from '../mocks/budgets';
|
||||
import type { Budget, BudgetPayload } from '../types/budget';
|
||||
|
||||
const queryKey = ['budgets'];
|
||||
@@ -22,15 +21,11 @@ export function useBudgetsQuery() {
|
||||
return useQuery<Budget[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/budgets');
|
||||
return (data.data as Budget[]).map(mapBudget);
|
||||
} catch (error) {
|
||||
console.warn('[budgets] fallback to sample data', error);
|
||||
return sampleBudgets;
|
||||
}
|
||||
const { data } = await apiClient.get('/budgets');
|
||||
return (data.data as Budget[]).map(mapBudget);
|
||||
},
|
||||
initialData: sampleBudgets
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { sampleTransactions } from '../mocks/transactions';
|
||||
import type { Transaction, TransactionPayload } from '../types/transaction';
|
||||
|
||||
const queryKey = ['transactions'];
|
||||
@@ -28,15 +27,11 @@ export function useTransactionsQuery() {
|
||||
return useQuery<Transaction[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/transactions');
|
||||
return (data.data as Transaction[]).map(mapTransaction);
|
||||
} catch (error) {
|
||||
console.warn('[transactions] fallback to sample data', error);
|
||||
return sampleTransactions;
|
||||
}
|
||||
const { data } = await apiClient.get('/transactions');
|
||||
return (data.data as Transaction[]).map(mapTransaction);
|
||||
},
|
||||
initialData: sampleTransactions
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +45,9 @@ export function useCreateTransactionMutation() {
|
||||
},
|
||||
onSuccess: (transaction) => {
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
|
||||
},
|
||||
onError: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router';
|
||||
import { apiClient } from '../../../lib/api/client';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const form = reactive({
|
||||
@@ -13,24 +14,34 @@ const form = reactive({
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
const redirectTarget = computed(() => {
|
||||
const target = route.query.redirect;
|
||||
return typeof target === 'string' && target.startsWith('/') ? target : '/';
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
try {
|
||||
const { data } = await apiClient.post('/auth/login', form);
|
||||
authStore.setSession(data.tokens, data.user);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
successMessage.value = '登录成功,正在跳转...';
|
||||
await router.replace(redirectTarget.value);
|
||||
} catch (error: any) {
|
||||
console.warn('login failed', error);
|
||||
errorMessage.value = '登录失败,请检查邮箱或密码。';
|
||||
const message = error?.response?.data?.message ?? '登录失败,请检查邮箱或密码。';
|
||||
errorMessage.value = message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enterDemoMode = () => {
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '已进入体验模式';
|
||||
authStore.setSession(
|
||||
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
|
||||
{
|
||||
@@ -40,7 +51,7 @@ const enterDemoMode = () => {
|
||||
preferredCurrency: 'CNY'
|
||||
}
|
||||
);
|
||||
router.push('/');
|
||||
void router.replace(redirectTarget.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -61,6 +72,7 @@ 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" />
|
||||
</label>
|
||||
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="text-sm text-emerald-500">{{ successMessage }}</p>
|
||||
<button
|
||||
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isLoading"
|
||||
|
||||
@@ -11,15 +11,23 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data: transactions } = useTransactionsQuery();
|
||||
const { data: budgets } = useBudgetsQuery();
|
||||
const transactionsQuery = useTransactionsQuery();
|
||||
const budgetsQuery = useBudgetsQuery();
|
||||
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
||||
const budgets = computed(() => budgetsQuery.data.value ?? []);
|
||||
const isInitialLoading = computed(() => transactionsQuery.isLoading.value || budgetsQuery.isLoading.value);
|
||||
const isRefreshing = computed(
|
||||
() =>
|
||||
(transactionsQuery.isFetching.value || budgetsQuery.isFetching.value) &&
|
||||
!isInitialLoading.value
|
||||
);
|
||||
|
||||
const greetingName = computed(() => authStore.profile?.displayName ?? '用户');
|
||||
|
||||
const monthlyStats = computed(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthTransactions = (transactions.value ?? []).filter(
|
||||
const monthTransactions = transactions.value.filter(
|
||||
(txn) => new Date(txn.occurredAt) >= startOfMonth
|
||||
);
|
||||
|
||||
@@ -38,7 +46,7 @@ const monthlyStats = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const activeBudget = computed(() => budgets.value?.[0]);
|
||||
const activeBudget = computed(() => budgets.value[0]);
|
||||
|
||||
const budgetUsagePercentage = computed(() => {
|
||||
if (!activeBudget.value || !activeBudget.value.amount) return 0;
|
||||
@@ -70,6 +78,9 @@ const navigateToBudgets = () => router.push('/settings');
|
||||
</header>
|
||||
|
||||
<main class="px-6 pb-24 space-y-8">
|
||||
<p v-if="isInitialLoading" class="text-xs text-gray-400 text-right">数据加载中...</p>
|
||||
<p v-else-if="isRefreshing" class="text-xs text-indigo-500 text-right">数据同步中...</p>
|
||||
|
||||
<OverviewCard
|
||||
title="本月总支出"
|
||||
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
|
||||
@@ -107,7 +118,15 @@ const navigateToBudgets = () => router.push('/settings');
|
||||
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
||||
</div>
|
||||
<div class="space-y-4 mt-4 pb-10">
|
||||
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
|
||||
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
|
||||
数据加载中...
|
||||
</div>
|
||||
<div v-else-if="transactions.length === 0" class="text-sm text-gray-400 text-center py-10">
|
||||
暂无交易记录,前往交易页添加一笔吧。
|
||||
</div>
|
||||
<template v-else>
|
||||
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
|
||||
import { App, type AppState } from '@capacitor/app';
|
||||
import { useRouter } from 'vue-router';
|
||||
import BudgetCard from '../../../components/budgets/BudgetCard.vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
|
||||
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { NotificationPermission } from '../../../lib/native/notification-permission';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const PREFERENCES_STORAGE_KEY = 'ai-bill/preferences';
|
||||
|
||||
const preferences = reactive({
|
||||
notifications: true,
|
||||
@@ -14,6 +20,25 @@ const preferences = reactive({
|
||||
experimentalLab: false
|
||||
});
|
||||
|
||||
const isAndroid = Capacitor.getPlatform() === 'android';
|
||||
const permissionLoading = ref(false);
|
||||
const notificationListenerGranted = ref(!isAndroid);
|
||||
const postNotificationGranted = ref(!isAndroid);
|
||||
let appStateListener: PluginListenerHandle | null = null;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Partial<typeof preferences>;
|
||||
Object.assign(preferences, parsed);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse stored preferences', error);
|
||||
localStorage.removeItem(PREFERENCES_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: budgets } = useBudgetsQuery();
|
||||
const createBudget = useCreateBudgetMutation();
|
||||
const deleteBudget = useDeleteBudgetMutation();
|
||||
@@ -45,6 +70,47 @@ const formattedLastNotification = computed(() => {
|
||||
|
||||
const copyFeedback = ref<string | null>(null);
|
||||
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const preferencesFeedback = ref<string | null>(null);
|
||||
let preferenceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const permissionStatusText = computed(() => {
|
||||
if (!isAndroid) {
|
||||
return '原生通知权限已就绪';
|
||||
}
|
||||
if (!notificationListenerGranted.value) {
|
||||
return '尚未授权读取通知,自动记账将受限';
|
||||
}
|
||||
if (!postNotificationGranted.value) {
|
||||
return '系统通知权限关闭,提醒可能无法送达';
|
||||
}
|
||||
return '通知权限已开启';
|
||||
});
|
||||
|
||||
const refreshPermissionStatus = async () => {
|
||||
if (!isAndroid) return;
|
||||
permissionLoading.value = true;
|
||||
try {
|
||||
const status = await NotificationPermission.checkStatus();
|
||||
notificationListenerGranted.value = Boolean(status.granted);
|
||||
postNotificationGranted.value = status.postNotificationsGranted !== false;
|
||||
} catch (error) {
|
||||
console.warn('Failed to check notification permission', error);
|
||||
} finally {
|
||||
permissionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openNotificationSettings = async () => {
|
||||
if (!isAndroid) return;
|
||||
permissionLoading.value = true;
|
||||
try {
|
||||
await NotificationPermission.requestAccess();
|
||||
} catch (error) {
|
||||
console.warn('Failed to open notification settings', error);
|
||||
} finally {
|
||||
permissionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showCopyFeedback = (message: string) => {
|
||||
copyFeedback.value = message;
|
||||
@@ -56,6 +122,32 @@ const showCopyFeedback = (message: string) => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const showPreferencesFeedback = (message: string) => {
|
||||
preferencesFeedback.value = message;
|
||||
if (preferenceTimeout) {
|
||||
clearTimeout(preferenceTimeout);
|
||||
}
|
||||
preferenceTimeout = setTimeout(() => {
|
||||
preferencesFeedback.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const requestSystemNotificationPermission = async () => {
|
||||
if (!isAndroid) return;
|
||||
permissionLoading.value = true;
|
||||
try {
|
||||
const result = await NotificationPermission.requestPostNotifications();
|
||||
if (result?.granted) {
|
||||
showPreferencesFeedback('系统通知权限已开启');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to request system notification permission', error);
|
||||
} finally {
|
||||
permissionLoading.value = false;
|
||||
await refreshPermissionStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -66,10 +158,42 @@ const copyText = async (text: string, label: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
preferences,
|
||||
(value) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const snapshot = { ...value };
|
||||
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
showPreferencesFeedback('偏好设置已更新');
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAndroid) {
|
||||
return;
|
||||
}
|
||||
void refreshPermissionStatus();
|
||||
App.addListener('appStateChange', ({ isActive }: AppState) => {
|
||||
if (isActive) {
|
||||
void refreshPermissionStatus();
|
||||
}
|
||||
}).then((handle: PluginListenerHandle) => {
|
||||
appStateListener = handle;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimeout) {
|
||||
clearTimeout(copyTimeout);
|
||||
}
|
||||
if (preferenceTimeout) {
|
||||
clearTimeout(preferenceTimeout);
|
||||
}
|
||||
if (appStateListener) {
|
||||
void appStateListener.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const resetBudgetForm = () => {
|
||||
@@ -98,6 +222,7 @@ const removeBudget = async (id: string) => {
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.clearSession();
|
||||
router.replace({ name: 'login', query: { redirect: '/' } });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -152,6 +277,50 @@ const handleLogout = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAndroid"
|
||||
class="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-indigo-600">{{ permissionStatusText }}</p>
|
||||
<p class="text-xs text-indigo-500">开启后可自动解析支付宝、微信等通知中的账单信息。</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-indigo-600 font-medium disabled:opacity-60"
|
||||
:disabled="permissionLoading"
|
||||
@click="refreshPermissionStatus"
|
||||
>
|
||||
{{ permissionLoading ? '检测中...' : '重新检测' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!notificationListenerGranted"
|
||||
class="px-3 py-2 text-xs rounded-xl bg-white text-indigo-600 border border-indigo-200 disabled:opacity-60"
|
||||
:disabled="permissionLoading"
|
||||
@click="openNotificationSettings"
|
||||
>
|
||||
打开系统设置
|
||||
</button>
|
||||
<button
|
||||
v-if="notificationListenerGranted && !postNotificationGranted"
|
||||
class="px-3 py-2 text-xs rounded-xl bg-indigo-500 text-white disabled:opacity-60"
|
||||
:disabled="permissionLoading"
|
||||
@click="requestSystemNotificationPermission"
|
||||
>
|
||||
允许系统通知
|
||||
</button>
|
||||
<span
|
||||
v-if="notificationListenerGranted && postNotificationGranted"
|
||||
class="text-xs text-indigo-500 flex items-center space-x-1"
|
||||
>
|
||||
<LucideIcon name="check" :size="14" />
|
||||
<span>权限已齐备</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -242,6 +411,7 @@ const handleLogout = () => {
|
||||
<input v-model="preferences.experimentalLab" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="preferencesFeedback" class="text-xs text-emerald-500">{{ preferencesFeedback }}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||
import {
|
||||
@@ -23,9 +23,10 @@ const form = reactive({
|
||||
source: 'manual'
|
||||
});
|
||||
|
||||
const { data: transactions } = useTransactionsQuery();
|
||||
const transactionsQuery = useTransactionsQuery();
|
||||
const createTransaction = useCreateTransactionMutation();
|
||||
const deleteTransaction = useDeleteTransactionMutation();
|
||||
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
||||
|
||||
const filters: Array<{ label: string; value: FilterOption }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@@ -34,12 +35,33 @@ const filters: Array<{ label: string; value: FilterOption }> = [
|
||||
];
|
||||
|
||||
const filteredTransactions = computed(() => {
|
||||
if (!transactions.value) return [];
|
||||
if (!transactions.value.length) return [];
|
||||
if (filter.value === 'all') return transactions.value;
|
||||
return transactions.value.filter((txn) => txn.type === filter.value);
|
||||
});
|
||||
|
||||
const isSaving = computed(() => createTransaction.isPending.value);
|
||||
const isInitialLoading = computed(() => transactionsQuery.isLoading.value);
|
||||
const isRefreshing = computed(() => transactionsQuery.isFetching.value && !transactionsQuery.isLoading.value);
|
||||
|
||||
const feedbackMessage = ref('');
|
||||
let feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showFeedback = (message: string) => {
|
||||
feedbackMessage.value = message;
|
||||
if (feedbackTimeout) {
|
||||
clearTimeout(feedbackTimeout);
|
||||
}
|
||||
feedbackTimeout = setTimeout(() => {
|
||||
feedbackMessage.value = '';
|
||||
}, 2400);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (feedbackTimeout) {
|
||||
clearTimeout(feedbackTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
const setFilter = (value: FilterOption) => {
|
||||
filter.value = value;
|
||||
@@ -67,13 +89,30 @@ const submit = async () => {
|
||||
notes: form.notes || undefined,
|
||||
occurredAt: new Date().toISOString()
|
||||
};
|
||||
await createTransaction.mutateAsync(payload);
|
||||
showSheet.value = false;
|
||||
resetForm();
|
||||
try {
|
||||
await createTransaction.mutateAsync(payload);
|
||||
showFeedback('新增交易已提交');
|
||||
showSheet.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.warn('Failed to create transaction', error);
|
||||
showFeedback('保存失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTransaction = async (id: string) => {
|
||||
await deleteTransaction.mutateAsync(id);
|
||||
try {
|
||||
await deleteTransaction.mutateAsync(id);
|
||||
showFeedback('交易已删除');
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete transaction', error);
|
||||
showFeedback('删除失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTransactions = async () => {
|
||||
await transactionsQuery.refetch();
|
||||
showFeedback('列表已更新');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -84,14 +123,35 @@ const removeTransaction = async (id: string) => {
|
||||
<p class="text-sm text-gray-500">轻松管理你的收支</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">交易记录</h1>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
|
||||
@click="showSheet = true"
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
class="px-4 py-2 border border-gray-200 text-sm rounded-xl text-gray-600 hover:bg-gray-50 disabled:opacity-60"
|
||||
:disabled="isRefreshing"
|
||||
@click="refreshTransactions"
|
||||
>
|
||||
<span v-if="isRefreshing" class="flex items-center space-x-2">
|
||||
<svg class="w-4 h-4 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span>刷新中</span>
|
||||
</span>
|
||||
<span v-else class="flex items-center space-x-2">
|
||||
<LucideIcon name="refresh-cw" :size="16" />
|
||||
<span>刷新</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
|
||||
@click="showSheet = true"
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="feedbackMessage" class="text-xs text-emerald-500 text-right">{{ feedbackMessage }}</p>
|
||||
|
||||
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
|
||||
<button
|
||||
v-for="item in filters"
|
||||
@@ -104,19 +164,33 @@ const removeTransaction = async (id: string) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 min-h-[140px]">
|
||||
<div v-if="isInitialLoading" class="flex justify-center py-16">
|
||||
<svg class="w-8 h-8 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-for="transaction in filteredTransactions"
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
v-else-if="filteredTransactions.length === 0"
|
||||
class="bg-white border border-dashed border-gray-200 rounded-2xl py-12 text-center text-sm text-gray-400"
|
||||
>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
<button
|
||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
|
||||
@click="removeTransaction(transaction.id)"
|
||||
暂无交易记录,点击「新增」开始记账。
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="transaction in filteredTransactions"
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
>
|
||||
<LucideIcon name="trash-2" :size="18" />
|
||||
</button>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
<button
|
||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
|
||||
@click="removeTransaction(transaction.id)"
|
||||
>
|
||||
<LucideIcon name="trash-2" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosHeaders, type AxiosRequestHeaders } from 'axios';
|
||||
import router from '../../router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4000/api';
|
||||
|
||||
@@ -7,11 +9,33 @@ export const apiClient = axios.create({
|
||||
timeout: 10_000
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.isAuthenticated && authStore.accessToken) {
|
||||
if (config.headers instanceof AxiosHeaders) {
|
||||
config.headers.set('Authorization', `Bearer ${authStore.accessToken}`);
|
||||
} else if (config.headers) {
|
||||
(config.headers as AxiosRequestHeaders).Authorization = `Bearer ${authStore.accessToken}`;
|
||||
} else {
|
||||
config.headers = { Authorization: `Bearer ${authStore.accessToken}` } as AxiosRequestHeaders;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
console.error('API error', error.response.status, error.response.data);
|
||||
if (error.response.status === 401) {
|
||||
const authStore = useAuthStore();
|
||||
authStore.clearSession();
|
||||
const current = router.currentRoute.value;
|
||||
if (!current.path.startsWith('/auth')) {
|
||||
void router.replace({ name: 'login', query: { redirect: current.fullPath } });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Network error', error.message);
|
||||
}
|
||||
|
||||
49
apps/frontend/src/lib/native/notification-permission.ts
Normal file
49
apps/frontend/src/lib/native/notification-permission.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
interface NotificationPermissionStatus {
|
||||
granted: boolean;
|
||||
postNotificationsGranted?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationPermissionPlugin {
|
||||
checkStatus(): Promise<NotificationPermissionStatus>;
|
||||
requestAccess(): Promise<{ opened: boolean } | void>;
|
||||
openSettings(): Promise<{ opened: boolean } | void>;
|
||||
requestPostNotifications(): Promise<{ granted: boolean }>;
|
||||
}
|
||||
|
||||
const WebFallback: NotificationPermissionPlugin = {
|
||||
async checkStatus() {
|
||||
return { granted: true, postNotificationsGranted: true };
|
||||
},
|
||||
async requestAccess() {
|
||||
return { opened: false };
|
||||
},
|
||||
async openSettings() {
|
||||
return { opened: false };
|
||||
},
|
||||
async requestPostNotifications() {
|
||||
return { granted: true };
|
||||
}
|
||||
};
|
||||
|
||||
export const NotificationPermission = registerPlugin<NotificationPermissionPlugin>('NotificationPermissions', {
|
||||
web: WebFallback
|
||||
});
|
||||
|
||||
export async function ensureNotificationPermissions() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return { granted: true, postNotificationsGranted: true } satisfies NotificationPermissionStatus;
|
||||
}
|
||||
|
||||
const status = await NotificationPermission.checkStatus();
|
||||
if (!status.postNotificationsGranted) {
|
||||
try {
|
||||
await NotificationPermission.requestPostNotifications();
|
||||
} catch (error) {
|
||||
console.warn('requestPostNotifications failed', error);
|
||||
}
|
||||
}
|
||||
return NotificationPermission.checkStatus();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
@@ -16,8 +17,13 @@ const queryClient = new QueryClient({
|
||||
}
|
||||
});
|
||||
|
||||
const authStore = useAuthStore(pinia);
|
||||
authStore.initialize();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(VueQueryPlugin, { queryClient });
|
||||
|
||||
app.mount('#app');
|
||||
router.isReady().finally(() => {
|
||||
app.mount('#app');
|
||||
});
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
|
||||
meta: { title: '仪表盘' }
|
||||
meta: { title: '仪表盘', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
name: 'transactions',
|
||||
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
||||
meta: { title: '交易记录' }
|
||||
meta: { title: '交易记录', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'analysis',
|
||||
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
|
||||
meta: { title: 'AI 智能分析' }
|
||||
meta: { title: 'AI 智能分析', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../features/settings/pages/SettingsPage.vue'),
|
||||
meta: { title: '设置' }
|
||||
meta: { title: '设置', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
@@ -56,6 +57,22 @@ const router = createRouter({
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return next({
|
||||
name: 'login',
|
||||
query: to.fullPath === '/' ? undefined : { redirect: to.fullPath }
|
||||
});
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated && to.name && ['login', 'register', 'forgot-password'].includes(to.name.toString())) {
|
||||
return next({ name: 'dashboard' });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `AI 记账 · ${to.meta.title}`;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'ai-bill/auth-session';
|
||||
|
||||
type AuthStatus = 'authenticated' | 'guest';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -30,12 +32,47 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
this.profile = profile;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
status: 'authenticated',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
profile
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
clearSession() {
|
||||
this.status = 'guest';
|
||||
this.accessToken = undefined;
|
||||
this.refreshToken = undefined;
|
||||
this.profile = undefined;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
initialize() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = JSON.parse(raw) as { status: AuthStatus; accessToken: string; refreshToken: string; profile: UserProfile };
|
||||
if (stored.status === 'authenticated' && stored.accessToken && stored.refreshToken && stored.profile) {
|
||||
this.status = stored.status;
|
||||
this.accessToken = stored.accessToken;
|
||||
this.refreshToken = stored.refreshToken;
|
||||
this.profile = stored.profile;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore auth session', error);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user