first commit
This commit is contained in:
24
apps/frontend/.gitignore
vendored
Normal file
24
apps/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
apps/frontend/.vscode/extensions.json
vendored
Normal file
3
apps/frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
apps/frontend/README.md
Normal file
5
apps/frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
apps/frontend/index.html
Normal file
13
apps/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
apps/frontend/package.json
Normal file
30
apps/frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-query": "^5",
|
||||
"axios": "^1.7",
|
||||
"lucide-vue-next": "^0.463.0",
|
||||
"pinia": "2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
apps/frontend/public/vite.svg
Normal file
1
apps/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
19
apps/frontend/src/App.vue
Normal file
19
apps/frontend/src/App.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, RouterView } from 'vue-router';
|
||||
import BottomTabBar from './components/navigation/BottomTabBar.vue';
|
||||
import QuickAddButton from './components/actions/QuickAddButton.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const showAppShell = computed(() => !route.path.startsWith('/auth'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||
<div class="max-w-xl mx-auto min-h-screen relative pb-24">
|
||||
<RouterView />
|
||||
<QuickAddButton v-if="showAppShell" />
|
||||
</div>
|
||||
<BottomTabBar v-if="showAppShell" />
|
||||
</div>
|
||||
</template>
|
||||
19
apps/frontend/src/assets/main.css
Normal file
19
apps/frontend/src/assets/main.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-100 text-gray-900 font-sans antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-indigo-500 hover:text-indigo-600;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
1
apps/frontend/src/assets/vue.svg
Normal file
1
apps/frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
18
apps/frontend/src/components/actions/QuickActionButton.vue
Normal file
18
apps/frontend/src/components/actions/QuickActionButton.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import LucideIcon from '../common/LucideIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string;
|
||||
label: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-gray-100 p-4 rounded-2xl flex items-center space-x-3 hover:bg-gray-200 transition"
|
||||
>
|
||||
<LucideIcon :name="props.icon" class="text-indigo-500" :size="22" />
|
||||
<span class="font-medium text-gray-800">{{ props.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
38
apps/frontend/src/components/actions/QuickAddButton.vue
Normal file
38
apps/frontend/src/components/actions/QuickAddButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import LucideIcon from '../common/LucideIcon.vue';
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed right-6 bottom-28 z-30">
|
||||
<button
|
||||
type="button"
|
||||
class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center shadow-xl hover:bg-blue-600 transition-transform"
|
||||
@click="toggle"
|
||||
>
|
||||
<LucideIcon
|
||||
name="plus"
|
||||
class="text-white transition-transform"
|
||||
:class="isOpen ? 'rotate-45' : ''"
|
||||
:size="32"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute bottom-20 right-1 flex flex-col items-end space-y-3"
|
||||
>
|
||||
<button class="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl">
|
||||
<LucideIcon name="camera" class="text-gray-700" :size="20" />
|
||||
</button>
|
||||
<button class="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl">
|
||||
<LucideIcon name="edit-3" class="text-gray-700" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
51
apps/frontend/src/components/budgets/BudgetCard.vue
Normal file
51
apps/frontend/src/components/budgets/BudgetCard.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Budget } from '../../types/budget';
|
||||
|
||||
const props = defineProps<{ budget: Budget }>();
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!props.budget.amount) return 0;
|
||||
return Math.min((props.budget.usage / props.budget.amount) * 100, 100);
|
||||
});
|
||||
|
||||
const thresholdPercent = computed(() => props.budget.threshold * 100);
|
||||
|
||||
const usageVariant = computed(() => {
|
||||
if (usagePercent.value >= thresholdPercent.value) return 'text-red-500';
|
||||
if (usagePercent.value >= thresholdPercent.value - 10) return 'text-amber-500';
|
||||
return 'text-emerald-500';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-gray-100 rounded-2xl p-5 space-y-3 bg-white shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ budget.period === 'monthly' ? '月度预算' : '周预算' }}</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ budget.category }}</h3>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">预算上限</p>
|
||||
<p class="text-xl font-bold text-gray-900">¥ {{ budget.amount.toFixed(0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>已使用 ¥ {{ budget.usage.toFixed(0) }}</span>
|
||||
<span>阈值 {{ thresholdPercent.toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-blue-500"
|
||||
:style="{ width: `${usagePercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm" :class="usageVariant">
|
||||
{{ usagePercent.toFixed(0) }}% 已使用
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
33
apps/frontend/src/components/cards/OverviewCard.vue
Normal file
33
apps/frontend/src/components/cards/OverviewCard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
amount: string;
|
||||
subtitle: string;
|
||||
progressLabel?: string;
|
||||
progressValue?: number;
|
||||
footer?: string;
|
||||
}>();
|
||||
|
||||
const progressStyle = computed(() =>
|
||||
props.progressValue !== undefined
|
||||
? { width: `${Math.min(props.progressValue, 100)}%` }
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-gradient-to-br from-indigo-500 to-blue-500 text-white p-6 rounded-3xl shadow-card">
|
||||
<p class="text-sm text-indigo-100/90">{{ title }}</p>
|
||||
<p class="text-4xl font-bold mt-2">{{ amount }}</p>
|
||||
<div v-if="progressValue !== undefined" class="mt-6 space-y-2">
|
||||
<p class="text-sm text-indigo-100/90">{{ progressLabel }}</p>
|
||||
<div class="w-full bg-indigo-400/50 rounded-full h-2.5">
|
||||
<div class="bg-white h-2.5 rounded-full" :style="progressStyle"></div>
|
||||
</div>
|
||||
<p class="text-right text-sm font-medium">{{ footer }}</p>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-sm text-indigo-100/90 mt-4">{{ subtitle }}</p>
|
||||
</section>
|
||||
</template>
|
||||
27
apps/frontend/src/components/common/LucideIcon.vue
Normal file
27
apps/frontend/src/components/common/LucideIcon.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { icons } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
size?: number;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const normalizeName = (value: string) =>
|
||||
value
|
||||
.split(/[-_ ]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('');
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const normalized = normalizeName(props.name);
|
||||
// @ts-expect-error dynamic lookup
|
||||
return icons[normalized] ?? icons.CircleHelp;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="iconComponent" :size="props.size ?? 24" :class="props.class" />
|
||||
</template>
|
||||
30
apps/frontend/src/components/navigation/BottomTabBar.vue
Normal file
30
apps/frontend/src/components/navigation/BottomTabBar.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
import LucideIcon from '../common/LucideIcon.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const tabs = [
|
||||
{ name: 'dashboard', label: '首页', icon: 'layout-dashboard', path: '/' },
|
||||
{ name: 'transactions', label: '记账', icon: 'list-checks', path: '/transactions' },
|
||||
{ name: 'analysis', label: 'AI', icon: 'sparkles', path: '/analysis' },
|
||||
{ name: 'settings', label: '设置', icon: 'settings', path: '/settings' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
|
||||
<div class="max-w-xl mx-auto flex justify-around items-center h-16 px-4">
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:to="tab.path"
|
||||
class="flex flex-col items-center space-y-1 text-xs font-medium"
|
||||
:class="route.path === tab.path ? 'text-indigo-500' : 'text-gray-400'"
|
||||
>
|
||||
<LucideIcon :name="tab.icon" :size="22" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import LucideIcon from '../common/LucideIcon.vue';
|
||||
import type { Transaction } from '../../types/transaction';
|
||||
|
||||
const props = defineProps<{ transaction: Transaction }>();
|
||||
|
||||
const isIncome = computed(() => props.transaction.type === 'income');
|
||||
|
||||
const amountClass = computed(() => (isIncome.value ? 'text-emerald-500' : 'text-red-600'));
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
const prefix = isIncome.value ? '+' : '-';
|
||||
return `${prefix}¥ ${props.transaction.amount.toFixed(2)}`;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.transaction.icon) return props.transaction.icon;
|
||||
if (props.transaction.source === 'notification') return 'bell-ring';
|
||||
if (props.transaction.source === 'ocr') return 'scan-text';
|
||||
if (props.transaction.source === 'ai') return 'sparkles';
|
||||
return isIncome.value ? 'arrow-down-right' : 'arrow-up-right';
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
switch (props.transaction.status) {
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-600';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-600';
|
||||
default:
|
||||
return 'bg-emerald-100 text-emerald-600';
|
||||
}
|
||||
});
|
||||
|
||||
const packageName = computed(() => props.transaction.metadata?.packageName as string | undefined);
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
switch (props.transaction.source) {
|
||||
case 'notification':
|
||||
return packageName.value ? `通知 · ${packageName.value}` : '自动通知';
|
||||
case 'ocr':
|
||||
return '票据识别';
|
||||
case 'ai':
|
||||
return 'AI 推荐';
|
||||
default:
|
||||
return '手动';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-4 p-4 bg-white border border-gray-100 rounded-xl">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
|
||||
<LucideIcon :name="iconName" class="text-indigo-500" :size="24" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-semibold text-gray-800">{{ transaction.title }}</p>
|
||||
<span class="text-[10px] uppercase tracking-wide text-gray-400">{{ transaction.currency }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{{ transaction.category }} · {{ sourceLabel }}</p>
|
||||
<p v-if="transaction.notes" class="text-xs text-gray-400 truncate">{{ transaction.notes }}</p>
|
||||
<div class="mt-1">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="statusColor">{{ transaction.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold" :class="amountClass">{{ formattedAmount }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
apps/frontend/src/composables/useAnalysis.ts
Normal file
32
apps/frontend/src/composables/useAnalysis.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query';
|
||||
import { computed, isRef, ref, type Ref } from 'vue';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { sampleSpendingInsight } from '../mocks/analysis';
|
||||
import type { CalorieResponse, SpendingInsight } from '../types/analysis';
|
||||
|
||||
export function useSpendingInsightQuery(input: Ref<'30d' | '90d'> | '30d' | '90d' = '30d') {
|
||||
const range = isRef(input) ? input : ref(input);
|
||||
|
||||
return useQuery<SpendingInsight>({
|
||||
queryKey: computed(() => ['analysis', range.value]),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/analysis/habits', { params: { range: range.value } });
|
||||
return data as SpendingInsight;
|
||||
} catch (error) {
|
||||
console.warn('[analysis] fallback to sample data', error);
|
||||
return sampleSpendingInsight;
|
||||
}
|
||||
},
|
||||
initialData: sampleSpendingInsight
|
||||
});
|
||||
}
|
||||
|
||||
export function useCalorieEstimationMutation() {
|
||||
return useMutation<CalorieResponse, unknown, string>({
|
||||
mutationFn: async (query: string) => {
|
||||
const { data } = await apiClient.post('/analysis/calories', { query });
|
||||
return data as CalorieResponse;
|
||||
}
|
||||
});
|
||||
}
|
||||
79
apps/frontend/src/composables/useBudgets.ts
Normal file
79
apps/frontend/src/composables/useBudgets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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'];
|
||||
|
||||
const mapBudget = (raw: any): Budget => ({
|
||||
id: raw.id,
|
||||
category: raw.category,
|
||||
amount: Number(raw.amount ?? 0),
|
||||
currency: raw.currency ?? 'CNY',
|
||||
period: raw.period ?? 'monthly',
|
||||
threshold: raw.threshold ?? 0.8,
|
||||
usage: raw.usage ?? raw.used ?? 0,
|
||||
userId: raw.userId ?? undefined,
|
||||
createdAt: raw.createdAt,
|
||||
updatedAt: raw.updatedAt
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
initialData: sampleBudgets
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBudgetMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: BudgetPayload) => {
|
||||
const { data } = await apiClient.post('/budgets', payload);
|
||||
return mapBudget(data.data);
|
||||
},
|
||||
onSuccess: (budget) => {
|
||||
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) => [...existing, budget]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBudgetMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: Partial<BudgetPayload> }) => {
|
||||
const { data } = await apiClient.patch(`/budgets/${id}`, payload);
|
||||
return mapBudget(data.data);
|
||||
},
|
||||
onSuccess: (budget) => {
|
||||
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) =>
|
||||
existing.map((item) => (item.id === budget.id ? budget : item))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudgetMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/budgets/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
queryClient.setQueryData<Budget[]>(queryKey, (existing = []) => existing.filter((item) => item.id !== id));
|
||||
}
|
||||
});
|
||||
}
|
||||
39
apps/frontend/src/composables/useNotifications.ts
Normal file
39
apps/frontend/src/composables/useNotifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
|
||||
export interface NotificationStatus {
|
||||
secretConfigured: boolean;
|
||||
secretHint: string | null;
|
||||
webhookSecret: string | null;
|
||||
packageWhitelist: string[];
|
||||
ingestedCount: number;
|
||||
lastNotificationAt: string | null;
|
||||
ingestEndpoint: string;
|
||||
}
|
||||
|
||||
const initialStatus: NotificationStatus = {
|
||||
secretConfigured: false,
|
||||
secretHint: null,
|
||||
webhookSecret: null,
|
||||
packageWhitelist: [],
|
||||
ingestedCount: 0,
|
||||
lastNotificationAt: null,
|
||||
ingestEndpoint: 'http://localhost:4000/api/transactions/notification'
|
||||
};
|
||||
|
||||
export function useNotificationStatusQuery() {
|
||||
return useQuery<NotificationStatus>({
|
||||
queryKey: ['notification-status'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/notifications/status');
|
||||
return data as NotificationStatus;
|
||||
} catch (error) {
|
||||
console.warn('[notifications] using fallback status', error);
|
||||
return initialStatus;
|
||||
}
|
||||
},
|
||||
initialData: initialStatus,
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
85
apps/frontend/src/composables/useTransactions.ts
Normal file
85
apps/frontend/src/composables/useTransactions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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'];
|
||||
|
||||
const mapTransaction = (raw: any): Transaction => ({
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: raw.description ?? raw.notes ?? undefined,
|
||||
icon: raw.icon,
|
||||
amount: Number(raw.amount ?? 0),
|
||||
currency: raw.currency ?? 'CNY',
|
||||
category: raw.category ?? '未分类',
|
||||
type: raw.type ?? 'expense',
|
||||
source: raw.source ?? 'manual',
|
||||
occurredAt: raw.occurredAt,
|
||||
status: raw.status ?? 'pending',
|
||||
notes: raw.notes,
|
||||
metadata: raw.metadata ?? undefined,
|
||||
userId: raw.userId ?? undefined,
|
||||
createdAt: raw.createdAt,
|
||||
updatedAt: raw.updatedAt
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
initialData: sampleTransactions
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTransactionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: TransactionPayload) => {
|
||||
const { data } = await apiClient.post('/transactions', payload);
|
||||
return mapTransaction(data.data);
|
||||
},
|
||||
onSuccess: (transaction) => {
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTransactionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: Partial<TransactionPayload> }) => {
|
||||
const { data } = await apiClient.patch(`/transactions/${id}`, payload);
|
||||
return mapTransaction(data.data);
|
||||
},
|
||||
onSuccess: (transaction) => {
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) =>
|
||||
existing?.map((item) => (item.id === transaction.id ? transaction : item)) ?? [transaction]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTransactionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/transactions/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => existing.filter((item) => item.id !== id));
|
||||
}
|
||||
});
|
||||
}
|
||||
149
apps/frontend/src/features/analysis/pages/AnalysisPage.vue
Normal file
149
apps/frontend/src/features/analysis/pages/AnalysisPage.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useSpendingInsightQuery, useCalorieEstimationMutation } from '../../../composables/useAnalysis';
|
||||
|
||||
const range = ref<'30d' | '90d'>('30d');
|
||||
const { data: insight } = useSpendingInsightQuery(range);
|
||||
const calorieQuery = useCalorieEstimationMutation();
|
||||
|
||||
const calorieForm = reactive({ query: '' });
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'assistant' | 'user';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const messages = ref<ChatMessage[]>([
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '你好!我分析了你近30天的消费。你似乎在“餐饮”上的支出较多,特别是工作日午餐和咖啡。这里有几条建议:\n- 尝试每周带 1-2 次午餐。\n- 将每天喝咖啡的习惯改为每周 3 次。\n- 设置一个“餐饮”类的单日预算。'
|
||||
}
|
||||
]);
|
||||
|
||||
const submitCalorieQuery = async () => {
|
||||
if (!calorieForm.query.trim()) return;
|
||||
const userMessage: ChatMessage = { role: 'user', text: calorieForm.query };
|
||||
messages.value.push(userMessage);
|
||||
|
||||
try {
|
||||
const result = await calorieQuery.mutateAsync(calorieForm.query);
|
||||
const response: ChatMessage = {
|
||||
role: 'assistant',
|
||||
text: `热量估算:${result.calories} kcal\n${result.insights?.join('\n') ?? ''}`
|
||||
};
|
||||
messages.value.push(response);
|
||||
} catch (error) {
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
text: 'AI 服务暂时不可用,请稍后重试。'
|
||||
});
|
||||
console.error(error);
|
||||
} finally {
|
||||
calorieForm.query = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10 pb-24 space-y-8">
|
||||
<header class="space-y-2">
|
||||
<p class="text-sm text-gray-500">AI 助手随时待命</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">AI 智能分析</h1>
|
||||
</header>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">消费洞察</h2>
|
||||
<div class="bg-gray-100 rounded-full p-1 flex space-x-1">
|
||||
<button
|
||||
v-for="option in ['30d', '90d']"
|
||||
:key="option"
|
||||
class="px-3 py-1 text-sm rounded-full"
|
||||
:class="range === option ? 'bg-white text-indigo-500 shadow-sm' : 'text-gray-500'"
|
||||
@click="range = option as '30d' | '90d'"
|
||||
>
|
||||
{{ option === '30d' ? '近30天' : '近90天' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 whitespace-pre-line">{{ insight?.summary }}</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="recommendation in insight?.recommendations ?? []"
|
||||
:key="recommendation"
|
||||
class="flex items-start space-x-2 text-sm text-gray-700"
|
||||
>
|
||||
<LucideIcon name="sparkles" class="mt-1 text-indigo-500" :size="16" />
|
||||
<span>{{ recommendation }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="grid grid-cols-3 gap-4 pt-2">
|
||||
<div
|
||||
v-for="category in insight?.categories ?? []"
|
||||
:key="category.name"
|
||||
class="bg-gray-50 rounded-2xl p-4"
|
||||
>
|
||||
<p class="text-sm text-gray-500">{{ category.name }}</p>
|
||||
<p class="text-lg font-semibold text-gray-900 mt-1">¥ {{ category.amount.toFixed(2) }}</p>
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="{
|
||||
'text-red-500': category.trend === 'up',
|
||||
'text-emerald-500': category.trend === 'down',
|
||||
'text-gray-500': category.trend === 'flat'
|
||||
}"
|
||||
>
|
||||
{{ category.trend === 'up' ? '上升' : category.trend === 'down' ? '下降' : '持平' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">卡路里估算</h2>
|
||||
<div class="flex space-x-3">
|
||||
<input
|
||||
v-model="calorieForm.query"
|
||||
type="text"
|
||||
placeholder="例如:一份麦辣鸡腿堡"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
class="w-14 h-14 rounded-2xl bg-indigo-500 text-white flex items-center justify-center hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="calorieQuery.isPending.value"
|
||||
@click="submitCalorieQuery"
|
||||
>
|
||||
<LucideIcon v-if="!calorieQuery.isPending.value" name="search" :size="22" />
|
||||
<svg
|
||||
v-else
|
||||
class="animate-spin w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-2xl p-4 space-y-4 h-72 overflow-y-auto">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
class="flex"
|
||||
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[70%] px-4 py-3 text-sm whitespace-pre-line"
|
||||
:class="message.role === 'user'
|
||||
? 'bg-gray-200 text-gray-800 rounded-2xl rounded-br-none'
|
||||
: 'bg-indigo-500 text-white rounded-2xl rounded-bl-none'"
|
||||
>
|
||||
{{ message.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
11
apps/frontend/src/features/auth/pages/AuthLayout.vue
Normal file
11
apps/frontend/src/features/auth/pages/AuthLayout.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500/20 to-blue-500/10 flex items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-md bg-white rounded-3xl shadow-xl p-8">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue
Normal file
54
apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { apiClient } from '../../../lib/api/client';
|
||||
|
||||
const form = reactive({ email: '' });
|
||||
const isLoading = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const submit = async () => {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
await apiClient.post('/auth/forgot-password', form);
|
||||
isCompleted.value = true;
|
||||
} catch (error) {
|
||||
console.warn('forgot password fallback', error);
|
||||
isCompleted.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-2 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">找回密码</h1>
|
||||
<p class="text-sm text-gray-500">填写注册邮箱,我们将发送重置链接</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!isCompleted" class="space-y-4">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
邮箱
|
||||
<input v-model="form.email" type="email" 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>
|
||||
<button
|
||||
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isLoading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isLoading ? '发送中...' : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-gray-50 rounded-2xl p-6 text-sm text-gray-600 space-y-3 text-center">
|
||||
<p class="font-semibold text-gray-900">邮件已发送</p>
|
||||
<p>请前往邮箱查收验证码,并在 10 分钟内完成密码重置。</p>
|
||||
<RouterLink to="/auth/login" class="text-indigo-500 font-medium">返回登录</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
81
apps/frontend/src/features/auth/pages/LoginPage.vue
Normal file
81
apps/frontend/src/features/auth/pages/LoginPage.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { apiClient } from '../../../lib/api/client';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const form = reactive({
|
||||
email: 'user@example.com',
|
||||
password: 'Password123!'
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const submit = async () => {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { data } = await apiClient.post('/auth/login', form);
|
||||
authStore.setSession(data.tokens, data.user);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.warn('login failed', error);
|
||||
errorMessage.value = '登录失败,请检查邮箱或密码。';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enterDemoMode = () => {
|
||||
authStore.setSession(
|
||||
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
|
||||
{
|
||||
id: 'demo-user',
|
||||
email: 'demo@ai-bill.app',
|
||||
displayName: '体验用户',
|
||||
preferredCurrency: 'CNY'
|
||||
}
|
||||
);
|
||||
router.push('/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<header class="space-y-2 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
|
||||
<p class="text-sm text-gray-500">使用 AI 记账,自动同步你的收支</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
邮箱
|
||||
<input v-model="form.email" type="email" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
密码
|
||||
<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>
|
||||
<button
|
||||
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isLoading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<button class="w-full py-3 border border-gray-200 text-gray-600 rounded-2xl text-sm" @click="enterDemoMode">
|
||||
体验模式进入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<RouterLink to="/auth/forgot-password" class="text-indigo-500">忘记密码?</RouterLink>
|
||||
<RouterLink to="/auth/register" class="text-indigo-500">新用户注册</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
102
apps/frontend/src/features/auth/pages/RegisterPage.vue
Normal file
102
apps/frontend/src/features/auth/pages/RegisterPage.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { apiClient } from '../../../lib/api/client';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const form = reactive({
|
||||
displayName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const submit = async () => {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
errorMessage.value = '两次输入的密码不一致';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
confirmPassword: form.confirmPassword,
|
||||
displayName: form.displayName,
|
||||
preferredCurrency: 'CNY'
|
||||
};
|
||||
const { data } = await apiClient.post('/auth/register', payload);
|
||||
authStore.setSession(data.tokens, data.user);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.warn('register failed', error);
|
||||
errorMessage.value = '注册失败,请稍后再试或联系支持。';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enterDemoMode = () => {
|
||||
authStore.setSession(
|
||||
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
|
||||
{
|
||||
id: 'demo-user',
|
||||
email: 'demo@ai-bill.app',
|
||||
displayName: form.displayName || '体验用户',
|
||||
preferredCurrency: 'CNY'
|
||||
}
|
||||
);
|
||||
router.push('/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-2 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">创建你的账户</h1>
|
||||
<p class="text-sm text-gray-500">开启 AI 自动记账之旅</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
昵称
|
||||
<input v-model="form.displayName" type="text" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
邮箱
|
||||
<input v-model="form.email" type="email" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
密码
|
||||
<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 class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
确认密码
|
||||
<input v-model="form.confirmPassword" 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>
|
||||
<button
|
||||
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isLoading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isLoading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
<button class="w-full py-3 border border-gray-200 text-gray-600 rounded-2xl text-sm" @click="enterDemoMode">
|
||||
体验模式进入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
已有账户?<RouterLink to="/auth/login" class="text-indigo-500">立即登录</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
115
apps/frontend/src/features/dashboard/pages/DashboardPage.vue
Normal file
115
apps/frontend/src/features/dashboard/pages/DashboardPage.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import OverviewCard from '../../../components/cards/OverviewCard.vue';
|
||||
import QuickActionButton from '../../../components/actions/QuickActionButton.vue';
|
||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { useTransactionsQuery } from '../../../composables/useTransactions';
|
||||
import { useBudgetsQuery } from '../../../composables/useBudgets';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data: transactions } = useTransactionsQuery();
|
||||
const { data: budgets } = useBudgetsQuery();
|
||||
|
||||
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(
|
||||
(txn) => new Date(txn.occurredAt) >= startOfMonth
|
||||
);
|
||||
|
||||
const expense = monthTransactions
|
||||
.filter((txn) => txn.type === 'expense')
|
||||
.reduce((total, txn) => total + txn.amount, 0);
|
||||
|
||||
const income = monthTransactions
|
||||
.filter((txn) => txn.type === 'income')
|
||||
.reduce((total, txn) => total + txn.amount, 0);
|
||||
|
||||
return {
|
||||
expense,
|
||||
income,
|
||||
net: income - expense
|
||||
};
|
||||
});
|
||||
|
||||
const activeBudget = computed(() => budgets.value?.[0]);
|
||||
|
||||
const budgetUsagePercentage = computed(() => {
|
||||
if (!activeBudget.value || !activeBudget.value.amount) return 0;
|
||||
return Math.min((activeBudget.value.usage / activeBudget.value.amount) * 100, 100);
|
||||
});
|
||||
|
||||
const budgetFooter = computed(() => {
|
||||
if (!activeBudget.value) return '';
|
||||
const remaining = Math.max(activeBudget.value.amount - activeBudget.value.usage, 0);
|
||||
return `剩余 ¥ ${remaining.toFixed(2)} / ¥ ${activeBudget.value.amount.toFixed(2)}`;
|
||||
});
|
||||
|
||||
const navigateToAnalysis = () => router.push('/analysis');
|
||||
const navigateToBudgets = () => router.push('/settings');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<header class="p-6 pt-10 bg-white sticky top-0 z-10">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">欢迎回来,</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ greetingName }}</h1>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<LucideIcon name="user" class="text-indigo-600" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-6 pb-24 space-y-8">
|
||||
<OverviewCard
|
||||
title="本月总支出"
|
||||
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
|
||||
subtitle="净收益"
|
||||
:progress-label="'预算剩余'"
|
||||
:progress-value="budgetUsagePercentage"
|
||||
:footer="budgetFooter"
|
||||
>
|
||||
</OverviewCard>
|
||||
|
||||
<section class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white border border-gray-100 rounded-2xl p-4">
|
||||
<p class="text-sm text-gray-500">本月收入</p>
|
||||
<p class="text-2xl font-semibold text-emerald-500 mt-1">¥ {{ monthlyStats.income.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="bg-white border border-gray-100 rounded-2xl p-4">
|
||||
<p class="text-sm text-gray-500">净收益</p>
|
||||
<p :class="['text-2xl font-semibold', monthlyStats.net >= 0 ? 'text-emerald-500' : 'text-red-500']">
|
||||
¥ {{ monthlyStats.net.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-gray-900">快捷操作</h2>
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<QuickActionButton icon="sparkles" label="消费分析" @click="navigateToAnalysis" />
|
||||
<QuickActionButton icon="wallet-cards" label="预算设置" @click="navigateToBudgets" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
316
apps/frontend/src/features/settings/pages/SettingsPage.vue
Normal file
316
apps/frontend/src/features/settings/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
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';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const preferences = reactive({
|
||||
notifications: true,
|
||||
aiSuggestions: true,
|
||||
experimentalLab: false
|
||||
});
|
||||
|
||||
const { data: budgets } = useBudgetsQuery();
|
||||
const createBudget = useCreateBudgetMutation();
|
||||
const deleteBudget = useDeleteBudgetMutation();
|
||||
const notificationStatusQuery = useNotificationStatusQuery();
|
||||
const notificationStatus = computed(() => notificationStatusQuery.data.value);
|
||||
|
||||
const budgetSheetOpen = ref(false);
|
||||
const budgetForm = reactive({
|
||||
category: '',
|
||||
amount: '',
|
||||
period: 'monthly',
|
||||
threshold: 0.8
|
||||
});
|
||||
|
||||
const isCreatingBudget = computed(() => createBudget.isPending.value);
|
||||
const canCopySecret = computed(() => Boolean(notificationStatus.value?.webhookSecret));
|
||||
const secretDisplay = computed(() => {
|
||||
const status = notificationStatus.value;
|
||||
if (!status) return '未配置';
|
||||
if (status.webhookSecret) return status.webhookSecret;
|
||||
if (status.secretHint) return status.secretHint;
|
||||
return '未配置';
|
||||
});
|
||||
const formattedLastNotification = computed(() => {
|
||||
const status = notificationStatus.value;
|
||||
if (!status?.lastNotificationAt) return '暂无记录';
|
||||
return new Date(status.lastNotificationAt).toLocaleString('zh-CN', { hour12: false });
|
||||
});
|
||||
|
||||
const copyFeedback = ref<string | null>(null);
|
||||
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showCopyFeedback = (message: string) => {
|
||||
copyFeedback.value = message;
|
||||
if (copyTimeout) {
|
||||
clearTimeout(copyTimeout);
|
||||
}
|
||||
copyTimeout = setTimeout(() => {
|
||||
copyFeedback.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const copyText = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showCopyFeedback(`${label} 已复制`);
|
||||
} catch (error) {
|
||||
console.warn('Clipboard copy failed', error);
|
||||
showCopyFeedback('复制失败,请手动复制');
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimeout) {
|
||||
clearTimeout(copyTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
const resetBudgetForm = () => {
|
||||
budgetForm.category = '';
|
||||
budgetForm.amount = '';
|
||||
budgetForm.period = 'monthly';
|
||||
budgetForm.threshold = 0.8;
|
||||
};
|
||||
|
||||
const submitBudget = async () => {
|
||||
if (!budgetForm.category || !budgetForm.amount) return;
|
||||
await createBudget.mutateAsync({
|
||||
category: budgetForm.category,
|
||||
amount: Number(budgetForm.amount),
|
||||
period: budgetForm.period as 'monthly' | 'weekly',
|
||||
threshold: budgetForm.threshold,
|
||||
currency: 'CNY'
|
||||
});
|
||||
resetBudgetForm();
|
||||
budgetSheetOpen.value = false;
|
||||
};
|
||||
|
||||
const removeBudget = async (id: string) => {
|
||||
await deleteBudget.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.clearSession();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10 pb-24 space-y-8">
|
||||
<header class="space-y-2">
|
||||
<p class="text-sm text-gray-500">掌控你的记账体验</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">设置中心</h1>
|
||||
</header>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">账户信息</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<LucideIcon name="user" class="text-indigo-600" :size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-gray-900">{{ authStore.profile?.displayName ?? '示例用户' }}</p>
|
||||
<p class="text-sm text-gray-500">{{ authStore.profile?.email ?? 'user@example.com' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-sm text-indigo-500 font-medium">编辑资料</button>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">预算管理</h2>
|
||||
<p class="text-sm text-gray-500">为高频分类设置预算阈值,接近时自动提醒。</p>
|
||||
<div class="space-y-4">
|
||||
<BudgetCard v-for="budget in budgets" :key="budget.id" :budget="budget" />
|
||||
<button
|
||||
class="w-full flex items-center justify-center space-x-2 border border-dashed border-indigo-400 rounded-2xl py-3 text-indigo-500 font-semibold"
|
||||
@click="budgetSheetOpen = true"
|
||||
>
|
||||
<LucideIcon name="plus" :size="20" />
|
||||
<span>新增预算</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">通知监听</h2>
|
||||
<p class="text-sm text-gray-500">配置原生插件的 Webhook 地址与安全密钥。</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-sm text-indigo-500 font-medium disabled:opacity-60"
|
||||
:disabled="notificationStatusQuery.isFetching.value"
|
||||
@click="notificationStatusQuery.refetch()"
|
||||
>
|
||||
{{ notificationStatusQuery.isFetching.value ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
</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">
|
||||
<span class="text-sm text-gray-500">Webhook Endpoint</span>
|
||||
<button
|
||||
class="text-xs text-indigo-500 font-medium"
|
||||
@click="notificationStatus?.ingestEndpoint && copyText(notificationStatus.ingestEndpoint, 'Webhook 地址')"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono break-all text-gray-800">
|
||||
{{ notificationStatus?.ingestEndpoint ?? 'http://localhost:4000/api/transactions/notification' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">HMAC 密钥</span>
|
||||
<button
|
||||
class="text-xs font-medium"
|
||||
:class="canCopySecret ? 'text-indigo-500' : 'text-gray-400 cursor-not-allowed'
|
||||
"
|
||||
:disabled="!canCopySecret"
|
||||
@click="notificationStatus?.webhookSecret && copyText(notificationStatus.webhookSecret, 'HMAC 密钥')"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono break-all text-gray-800">
|
||||
{{ secretDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="border border-gray-100 rounded-2xl p-4">
|
||||
<p class="text-xs text-gray-500">通知入库</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ notificationStatus?.ingestedCount ?? 0 }}</p>
|
||||
</div>
|
||||
<div class="border border-gray-100 rounded-2xl p-4">
|
||||
<p class="text-xs text-gray-500">最新入库</p>
|
||||
<p class="text-sm text-gray-900">{{ formattedLastNotification }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-2">包名白名单</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pkg in notificationStatus?.packageWhitelist ?? []"
|
||||
:key="pkg"
|
||||
class="px-3 py-1 text-xs rounded-full bg-gray-100 text-gray-700"
|
||||
>
|
||||
{{ pkg }}
|
||||
</span>
|
||||
<span v-if="(notificationStatus?.packageWhitelist?.length ?? 0) === 0" class="text-xs text-gray-400">
|
||||
暂无包名配置
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="copyFeedback" class="text-xs text-emerald-500">{{ copyFeedback }}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">个性化偏好</h2>
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">通知提醒</p>
|
||||
<p class="text-sm text-gray-500">开启后自动检测通知并辅助记账</p>
|
||||
</div>
|
||||
<input v-model="preferences.notifications" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||||
</label>
|
||||
<label class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">AI 理财建议</p>
|
||||
<p class="text-sm text-gray-500">根据消费习惯提供个性化提示</p>
|
||||
</div>
|
||||
<input v-model="preferences.aiSuggestions" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||||
</label>
|
||||
<label class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">实验室功能</p>
|
||||
<p class="text-sm text-gray-500">抢先体验新功能(可能不稳定)</p>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">安全与隐私</h2>
|
||||
<div class="space-y-3 text-sm text-gray-600">
|
||||
<p>· 支持密码找回(邮箱验证码)</p>
|
||||
<p>· 可申请导出全部数据</p>
|
||||
<p>· 支持平台内删除账户</p>
|
||||
</div>
|
||||
<button class="w-full py-3 bg-red-50 text-red-600 font-semibold rounded-2xl" @click="handleLogout">退出登录</button>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="budgetSheetOpen"
|
||||
class="fixed inset-0 bg-black/40 flex items-end justify-center"
|
||||
@click.self="budgetSheetOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-xl bg-white rounded-t-3xl p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">新增预算</h3>
|
||||
<button @click="budgetSheetOpen = false">
|
||||
<LucideIcon name="x" :size="22" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
分类
|
||||
<input v-model="budgetForm.category" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
金额 (¥)
|
||||
<input v-model="budgetForm.amount" type="number" min="0" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
周期
|
||||
<select v-model="budgetForm.period" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="monthly">月度</option>
|
||||
<option value="weekly">每周</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
阈值 ({{ Math.round(budgetForm.threshold * 100) }}%)
|
||||
<input v-model.number="budgetForm.threshold" type="range" min="0.4" max="1" step="0.05" />
|
||||
</label>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button class="px-4 py-2 text-sm text-gray-500" @click="budgetSheetOpen = false">取消</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isCreatingBudget"
|
||||
@click="submitBudget"
|
||||
>
|
||||
{{ isCreatingBudget ? '保存中...' : '保存预算' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="budgets && budgets.length" class="border-t border-gray-100 pt-4 space-y-2">
|
||||
<p class="text-xs text-gray-500">长按预算卡片可在未来版本中编辑阈值。当前可在此快速删除。</p>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="budget in budgets"
|
||||
:key="budget.id"
|
||||
class="w-full text-left text-sm text-red-500 border border-red-200 rounded-xl px-4 py-2"
|
||||
@click="removeBudget(budget.id)"
|
||||
>
|
||||
删除「{{ budget.category }}」预算
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||
import {
|
||||
useTransactionsQuery,
|
||||
useCreateTransactionMutation,
|
||||
useDeleteTransactionMutation
|
||||
} from '../../../composables/useTransactions';
|
||||
import type { TransactionPayload } from '../../../types/transaction';
|
||||
|
||||
type FilterOption = 'all' | 'expense' | 'income';
|
||||
|
||||
const filter = ref<FilterOption>('all');
|
||||
const showSheet = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
amount: '',
|
||||
category: '',
|
||||
type: 'expense',
|
||||
notes: '',
|
||||
source: 'manual'
|
||||
});
|
||||
|
||||
const { data: transactions } = useTransactionsQuery();
|
||||
const createTransaction = useCreateTransactionMutation();
|
||||
const deleteTransaction = useDeleteTransactionMutation();
|
||||
|
||||
const filters: Array<{ label: string; value: FilterOption }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '支出', value: 'expense' },
|
||||
{ label: '收入', value: 'income' }
|
||||
];
|
||||
|
||||
const filteredTransactions = computed(() => {
|
||||
if (!transactions.value) return [];
|
||||
if (filter.value === 'all') return transactions.value;
|
||||
return transactions.value.filter((txn) => txn.type === filter.value);
|
||||
});
|
||||
|
||||
const isSaving = computed(() => createTransaction.isPending.value);
|
||||
|
||||
const setFilter = (value: FilterOption) => {
|
||||
filter.value = value;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.title = '';
|
||||
form.amount = '';
|
||||
form.category = '';
|
||||
form.type = 'expense';
|
||||
form.source = 'manual';
|
||||
form.notes = '';
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.title || !form.amount) return;
|
||||
const payload: TransactionPayload = {
|
||||
title: form.title,
|
||||
amount: Math.abs(Number(form.amount)),
|
||||
category: form.category || '未分类',
|
||||
type: form.type as TransactionPayload['type'],
|
||||
source: form.source as TransactionPayload['source'],
|
||||
status: 'pending',
|
||||
currency: 'CNY',
|
||||
notes: form.notes || undefined,
|
||||
occurredAt: new Date().toISOString()
|
||||
};
|
||||
await createTransaction.mutateAsync(payload);
|
||||
showSheet.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const removeTransaction = async (id: string) => {
|
||||
await deleteTransaction.mutateAsync(id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10 pb-24 space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
|
||||
<button
|
||||
v-for="item in filters"
|
||||
:key="item.value"
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium"
|
||||
:class="filter === item.value ? 'bg-indigo-500 text-white' : 'text-gray-500'"
|
||||
@click="setFilter(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="transaction in filteredTransactions"
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
>
|
||||
<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
|
||||
v-if="showSheet"
|
||||
class="fixed inset-0 bg-black/40 flex items-end justify-center"
|
||||
@click.self="showSheet = false"
|
||||
>
|
||||
<div class="w-full max-w-xl bg-white rounded-t-3xl p-6 space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold text-gray-900">快速记一笔</h2>
|
||||
<button @click="showSheet = false">
|
||||
<LucideIcon name="x" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
标题
|
||||
<input v-model="form.title" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
金额
|
||||
<input v-model="form.amount" type="number" min="0" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
类型
|
||||
<select v-model="form.type" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="expense">支出</option>
|
||||
<option value="income">收入</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
分类
|
||||
<input v-model="form.category" type="text" placeholder="如:餐饮" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
来源
|
||||
<select v-model="form.source" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="manual">手动</option>
|
||||
<option value="notification">通知自动</option>
|
||||
<option value="ocr">票据识别</option>
|
||||
<option value="ai">AI 推荐</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||||
备注
|
||||
<textarea v-model="form.notes" rows="2" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</label>
|
||||
<button
|
||||
class="w-full bg-indigo-500 text-white py-3 rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||
:disabled="isSaving"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
21
apps/frontend/src/lib/api/client.ts
Normal file
21
apps/frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4000/api';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL,
|
||||
timeout: 10_000
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
console.error('API error', error.response.status, error.response.data);
|
||||
} else {
|
||||
console.error('Network error', error.message);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
23
apps/frontend/src/main.ts
Normal file
23
apps/frontend/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(VueQueryPlugin, { queryClient });
|
||||
|
||||
app.mount('#app');
|
||||
16
apps/frontend/src/mocks/analysis.ts
Normal file
16
apps/frontend/src/mocks/analysis.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { SpendingInsight } from '../types/analysis';
|
||||
|
||||
export const sampleSpendingInsight: SpendingInsight = {
|
||||
range: '30d',
|
||||
summary: '本期餐饮支出占比 42%,建议每周至少 2 天自带午餐。',
|
||||
recommendations: [
|
||||
'为餐饮类别设置每日 80 元的预算提醒。',
|
||||
'将咖啡消费减半,节省 200 元/月。',
|
||||
'尝试将高频支出标记标签,便于 AI 学习偏好。'
|
||||
],
|
||||
categories: [
|
||||
{ name: '餐饮', amount: 1250.4, trend: 'up' },
|
||||
{ name: '交通', amount: 420.0, trend: 'flat' },
|
||||
{ name: '生活服务', amount: 389.5, trend: 'down' }
|
||||
]
|
||||
};
|
||||
24
apps/frontend/src/mocks/budgets.ts
Normal file
24
apps/frontend/src/mocks/budgets.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Budget } from '../types/budget';
|
||||
|
||||
export const sampleBudgets: Budget[] = [
|
||||
{
|
||||
id: 'budget-001',
|
||||
category: '餐饮',
|
||||
amount: 2000,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
usage: 1250,
|
||||
threshold: 0.8,
|
||||
userId: 'demo-user'
|
||||
},
|
||||
{
|
||||
id: 'budget-002',
|
||||
category: '交通',
|
||||
amount: 600,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
usage: 320,
|
||||
threshold: 0.75,
|
||||
userId: 'demo-user'
|
||||
}
|
||||
];
|
||||
57
apps/frontend/src/mocks/transactions.ts
Normal file
57
apps/frontend/src/mocks/transactions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Transaction } from '../types/transaction';
|
||||
|
||||
export const sampleTransactions: Transaction[] = [
|
||||
{
|
||||
id: 'txn-001',
|
||||
title: '星巴克咖啡',
|
||||
description: '餐饮 | 早餐',
|
||||
icon: 'coffee',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
occurredAt: '2024-04-03T09:15:00.000Z',
|
||||
status: 'confirmed',
|
||||
metadata: { packageName: 'com.eg.android.AlipayGphone' }
|
||||
},
|
||||
{
|
||||
id: 'txn-002',
|
||||
title: '地铁充值',
|
||||
description: '交通 | 交通卡充值',
|
||||
icon: 'tram-front',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
occurredAt: '2024-04-02T18:40:00.000Z',
|
||||
status: 'confirmed'
|
||||
},
|
||||
{
|
||||
id: 'txn-003',
|
||||
title: '午餐报销',
|
||||
description: '收入 | 公司报销',
|
||||
icon: 'wallet',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
occurredAt: '2024-04-01T12:00:00.000Z',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
id: 'txn-004',
|
||||
title: '超市购物 (OCR)',
|
||||
description: '购物 | 票据识别',
|
||||
icon: 'shopping-bag',
|
||||
amount: 128.5,
|
||||
currency: 'CNY',
|
||||
category: '购物',
|
||||
type: 'expense',
|
||||
source: 'ocr',
|
||||
occurredAt: '2024-03-31T16:20:00.000Z',
|
||||
status: 'confirmed'
|
||||
}
|
||||
];
|
||||
67
apps/frontend/src/router/index.ts
Normal file
67
apps/frontend/src/router/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
|
||||
meta: { title: '仪表盘' }
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
name: 'transactions',
|
||||
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
||||
meta: { title: '交易记录' }
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'analysis',
|
||||
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
|
||||
meta: { title: 'AI 智能分析' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../features/settings/pages/SettingsPage.vue'),
|
||||
meta: { title: '设置' }
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('../features/auth/pages/AuthLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('../features/auth/pages/LoginPage.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: 'register',
|
||||
component: () => import('../features/auth/pages/RegisterPage.vue'),
|
||||
meta: { title: '注册' }
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('../features/auth/pages/ForgotPasswordPage.vue'),
|
||||
meta: { title: '找回密码' }
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.title) {
|
||||
document.title = `AI 记账 · ${to.meta.title}`;
|
||||
} else {
|
||||
document.title = 'AI 记账';
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
41
apps/frontend/src/stores/auth.ts
Normal file
41
apps/frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
type AuthStatus = 'authenticated' | 'guest';
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
preferredCurrency: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
status: AuthStatus;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
profile?: UserProfile;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
status: 'guest'
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => state.status === 'authenticated'
|
||||
},
|
||||
actions: {
|
||||
setSession(tokens: { accessToken: string; refreshToken: string }, profile: UserProfile) {
|
||||
this.status = 'authenticated';
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
this.profile = profile;
|
||||
},
|
||||
clearSession() {
|
||||
this.status = 'guest';
|
||||
this.accessToken = undefined;
|
||||
this.refreshToken = undefined;
|
||||
this.profile = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
18
apps/frontend/src/types/analysis.ts
Normal file
18
apps/frontend/src/types/analysis.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface SpendingCategoryInsight {
|
||||
name: string;
|
||||
amount: number;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
}
|
||||
|
||||
export interface SpendingInsight {
|
||||
range: '30d' | '90d';
|
||||
summary: string;
|
||||
recommendations: string[];
|
||||
categories: SpendingCategoryInsight[];
|
||||
}
|
||||
|
||||
export interface CalorieResponse {
|
||||
query: string;
|
||||
calories: number;
|
||||
insights: string[];
|
||||
}
|
||||
22
apps/frontend/src/types/budget.ts
Normal file
22
apps/frontend/src/types/budget.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface Budget {
|
||||
id: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: 'monthly' | 'weekly';
|
||||
threshold: number;
|
||||
usage: number;
|
||||
userId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface BudgetPayload {
|
||||
category: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
period?: 'monthly' | 'weekly';
|
||||
threshold?: number;
|
||||
usage?: number;
|
||||
userId?: string;
|
||||
}
|
||||
32
apps/frontend/src/types/transaction.ts
Normal file
32
apps/frontend/src/types/transaction.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
type: 'expense' | 'income';
|
||||
source: 'manual' | 'notification' | 'ocr' | 'ai';
|
||||
occurredAt: string;
|
||||
status: 'pending' | 'confirmed' | 'rejected';
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TransactionPayload {
|
||||
title: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
category: string;
|
||||
type: Transaction['type'];
|
||||
source?: Transaction['source'];
|
||||
status?: Transaction['status'];
|
||||
occurredAt: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
}
|
||||
24
apps/frontend/tailwind.config.js
Normal file
24
apps/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
indigo: '#6366F1',
|
||||
blue: '#3B82F6'
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 20px 40px -20px rgba(99,102,241,0.35)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
16
apps/frontend/tsconfig.app.json
Normal file
16
apps/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
apps/frontend/tsconfig.json
Normal file
7
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/frontend/tsconfig.node.json
Normal file
26
apps/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
apps/frontend/vite.config.ts
Normal file
7
apps/frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Reference in New Issue
Block a user