first commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
apps/frontend/dist
|
||||
apps/backend/dist
|
||||
*.local
|
||||
.env
|
||||
.DS_Store
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# AI 记账应用框架
|
||||
|
||||
基于《项目设计蓝图》与《UI 设计规范》搭建的前后端 Monorepo。当前版本提供可运行的基础框架、示例数据与 API 接口骨架,现已补齐账户认证、预算管理、交易通知入库等核心流程,便于继续迭代为完整的 AI 记账应用。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── apps
|
||||
│ ├── backend # Node.js + Express + TypeScript,提供 REST API 骨架
|
||||
│ └── frontend # Vue 3 + Vite + TailwindCSS,包含移动端风格 UI
|
||||
├── pnpm-workspace.yaml
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 安装依赖(首次执行已完成可跳过):
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
2. (可选)启动内置 MongoDB(需 Docker 环境):
|
||||
```bash
|
||||
pnpm db:up
|
||||
pnpm --filter backend seed # 首次运行:写入默认用户/预算/交易
|
||||
```
|
||||
结束后可通过 `pnpm db:down` 停止容器。
|
||||
3. 启动后端(默认端口 4000):
|
||||
```bash
|
||||
pnpm dev:backend
|
||||
```
|
||||
4. 启动前端(默认端口 5173):
|
||||
```bash
|
||||
pnpm dev:frontend
|
||||
```
|
||||
5. 或者并行启动:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 后端说明(apps/backend)
|
||||
- 技术栈:Express、TypeScript、Zod、Mongoose、Pino。
|
||||
- `.env.example` 给出了基础环境变量;开发时可复制为 `.env.local`。
|
||||
- 支持 MongoDB 或内存双模式运行:当 `MONGODB_URI` 未设置时自动回退到示例数据,方便离线体验。若使用内置 Docker 服务,可配置 `MONGODB_URI=mongodb://root:example@127.0.0.1:27017/ai-bill?authSource=admin`。
|
||||
- 核心 API:
|
||||
- `POST /api/auth/login|register|refresh|logout|forgot-password`
|
||||
- `GET /api/transactions`、`POST /api/transactions`、`PATCH /api/transactions/:id`、`DELETE /api/transactions/:id`
|
||||
- `POST /api/transactions/notification`(HMAC 签名校验的通知入库)
|
||||
- `GET /api/analysis/habits`(基于实时交易聚合)与 `POST /api/analysis/calories`
|
||||
- `GET /api/budgets`、`POST /api/budgets`、`PATCH /api/budgets/:id`、`DELETE /api/budgets/:id`
|
||||
- `GET /api/notifications/status`(返回 webhook 地址、密钥提示、通知包名白名单及最近入库信息)
|
||||
- `pnpm --filter backend build` 进行编译,`pnpm --filter backend lint` 执行 ESLint。
|
||||
|
||||
## 前端说明(apps/frontend)
|
||||
- 技术栈:Vue 3、TypeScript、Pinia、Vue Router、@tanstack/vue-query、TailwindCSS。
|
||||
- UI 与交互设计遵循 `UI设计规范 design_system.md` 与 `ui_mockup.html`。
|
||||
- 关键页面与能力:
|
||||
- 仪表盘:展示当月收入/支出统计、预算剩余、快捷操作。
|
||||
- 交易列表:支持按类型筛选、新增、备注、删除,区分数据来源(手动/通知/OCR/AI)。
|
||||
- AI 分析:实时消费洞察 + 卡路里估算对话体验。
|
||||
- 设置中心:账户信息、偏好开关、预算卡片管理(新增/删除、阈值滑杆)。
|
||||
- 认证流程:登录/注册/找回密码,提供「体验模式」快捷入口。
|
||||
- 默认通过 Axios 访问后端 API,无法连接时自动回退到本地示例数据。
|
||||
- 若需要跨端访问,可在 `apps/frontend/.env.local` 中调整 `VITE_API_BASE_URL`(默认指向 `http://localhost:4000/api`)。
|
||||
- 构建与预览:
|
||||
```bash
|
||||
pnpm --filter frontend build
|
||||
pnpm --filter frontend preview
|
||||
```
|
||||
|
||||
## 下一步建议
|
||||
- 接入真实登录态(JWT 校验、刷新、登出)、以及通知监听插件(Android `NotificationListenerService`)调用示例。
|
||||
- 引入 OCR / LLM 能力(Google Vision、Gemini 等)补齐票据识别与消费分析的真实数据链路。
|
||||
- 补充自动化测试:Vitest(前端组件/状态)、Jest + Supertest(后端 API)、Playwright(端到端流程)。
|
||||
- 优化移动端体验:离线缓存、渐进式加载、通知权限引导流程。
|
||||
13
apps/backend/.env.example
Normal file
13
apps/backend/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/ai-bill
|
||||
MONGODB_DB=ai-bill
|
||||
LOG_LEVEL=debug
|
||||
JWT_SECRET=dev-secret-change-me
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-me
|
||||
NOTIFICATION_WEBHOOK_SECRET=dev-webhook-secret
|
||||
NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
|
||||
GEMINI_API_KEY=
|
||||
OCR_PROJECT_ID=
|
||||
FILE_STORAGE_BUCKET=
|
||||
19
apps/backend/.eslintrc.cjs
Normal file
19
apps/backend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json']
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }]
|
||||
}
|
||||
};
|
||||
44
apps/backend/package.json
Normal file
44
apps/backend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file .env.local src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"seed": "tsx --env-file .env.local src/scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^7.6.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-rate-limit": "^6.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"tsx": "^4.7.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
51
apps/backend/src/app.ts
Normal file
51
apps/backend/src/app.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import pinoHttp from 'pino-http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { router } from './routes/index.js';
|
||||
import { notFoundHandler } from './middlewares/not-found.js';
|
||||
import { errorHandler } from './middlewares/error-handler.js';
|
||||
import { logger } from './config/logger.js';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: '*',
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
limit: 100,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
autoLogging: false
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'AI Bill API',
|
||||
version: '0.1.0',
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', router);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
45
apps/backend/src/config/database.ts
Normal file
45
apps/backend/src/config/database.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { env } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export async function connectToDatabase(): Promise<void> {
|
||||
if (!env.MONGODB_URI) {
|
||||
logger.warn('Skipping MongoDB connection: MONGODB_URI is not set.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const connectionUri = env.MONGODB_URI;
|
||||
const options: mongoose.ConnectOptions = {};
|
||||
|
||||
if (env.MONGODB_DB) {
|
||||
options.dbName = env.MONGODB_DB;
|
||||
}
|
||||
|
||||
if (env.MONGODB_USER && env.MONGODB_PASS) {
|
||||
options.auth = {
|
||||
username: env.MONGODB_USER,
|
||||
password: env.MONGODB_PASS
|
||||
};
|
||||
options.authMechanism = 'SCRAM-SHA-256';
|
||||
}
|
||||
|
||||
if (env.MONGODB_AUTH_SOURCE) {
|
||||
options.authSource = env.MONGODB_AUTH_SOURCE;
|
||||
}
|
||||
|
||||
logger.info({ uri: connectionUri, db: options.dbName, authSource: options.authSource }, 'Attempting MongoDB connection');
|
||||
await mongoose.connect(connectionUri, options);
|
||||
logger.info('MongoDB connected');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to connect to MongoDB');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectFromDatabase(): Promise<void> {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.disconnect();
|
||||
logger.info('MongoDB disconnected');
|
||||
}
|
||||
}
|
||||
45
apps/backend/src/config/env.ts
Normal file
45
apps/backend/src/config/env.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_NOTIFICATION_PACKAGES = [
|
||||
'com.eg.android.AlipayGphone',
|
||||
'com.tencent.mm',
|
||||
'com.tencent.mobileqq',
|
||||
'com.google.android.gms',
|
||||
'com.android.mms'
|
||||
];
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
MONGODB_URI: z.string().url().optional(),
|
||||
MONGODB_DB: z.string().optional(),
|
||||
MONGODB_USER: z.string().optional(),
|
||||
MONGODB_PASS: z.string().optional(),
|
||||
MONGODB_AUTH_SOURCE: z.string().optional(),
|
||||
JWT_SECRET: z.string().min(10).default('change-me-in-prod'),
|
||||
JWT_REFRESH_SECRET: z.string().min(10).optional(),
|
||||
NOTIFICATION_WEBHOOK_SECRET: z.string().min(10).optional(),
|
||||
NOTIFICATION_ALLOWED_PACKAGES: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
OCR_PROJECT_ID: z.string().optional(),
|
||||
FILE_STORAGE_BUCKET: z.string().optional()
|
||||
})
|
||||
.transform((values) => ({
|
||||
...values,
|
||||
isProduction: values.NODE_ENV === 'production',
|
||||
notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES
|
||||
? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean)
|
||||
: DEFAULT_NOTIFICATION_PACKAGES
|
||||
}));
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration.');
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export type Env = typeof env;
|
||||
17
apps/backend/src/config/logger.ts
Normal file
17
apps/backend/src/config/logger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const level = process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||
|
||||
export const logger = pino({
|
||||
level,
|
||||
transport:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname'
|
||||
}
|
||||
}
|
||||
});
|
||||
37
apps/backend/src/main.ts
Normal file
37
apps/backend/src/main.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dotenv/config';
|
||||
import 'express-async-errors';
|
||||
import http from 'node:http';
|
||||
import { createApp } from './app.js';
|
||||
import { connectToDatabase, disconnectFromDatabase } from './config/database.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './config/logger.js';
|
||||
|
||||
async function bootstrap() {
|
||||
await connectToDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(env.PORT, env.HOST, () => {
|
||||
logger.info(`API server listening on http://${env.HOST}:${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutting down gracefully');
|
||||
server.close(async (error) => {
|
||||
if (error) {
|
||||
logger.error({ error }, 'Error during HTTP shutdown');
|
||||
}
|
||||
await disconnectFromDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
logger.error({ error }, 'Failed to bootstrap application');
|
||||
process.exit(1);
|
||||
});
|
||||
29
apps/backend/src/middlewares/error-handler.ts
Normal file
29
apps/backend/src/middlewares/error-handler.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { logger } from '../config/logger.js';
|
||||
|
||||
type HttpError = Error & {
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err: HttpError, _req, res, _next) => {
|
||||
void _next;
|
||||
if (err instanceof ZodError) {
|
||||
const formatted = err.flatten();
|
||||
return res.status(400).json({
|
||||
message: 'Invalid request payload',
|
||||
errors: formatted.fieldErrors
|
||||
});
|
||||
}
|
||||
|
||||
const status = err.statusCode ?? 500;
|
||||
if (status >= 500) {
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
}
|
||||
|
||||
return res.status(status).json({
|
||||
message: err.message || 'Internal Server Error',
|
||||
details: err.details
|
||||
});
|
||||
};
|
||||
5
apps/backend/src/middlewares/not-found.ts
Normal file
5
apps/backend/src/middlewares/not-found.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, res) => {
|
||||
res.status(404).json({ message: 'Resource not found' });
|
||||
};
|
||||
30
apps/backend/src/middlewares/validate-request.ts
Normal file
30
apps/backend/src/middlewares/validate-request.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { ZodTypeAny, ZodError } from 'zod';
|
||||
|
||||
interface ValidationSchema {
|
||||
body?: ZodTypeAny;
|
||||
query?: ZodTypeAny;
|
||||
params?: ZodTypeAny;
|
||||
}
|
||||
|
||||
export const validateRequest = (schema: ValidationSchema): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
if (schema.body) {
|
||||
req.body = schema.body.parse(req.body);
|
||||
}
|
||||
if (schema.query) {
|
||||
req.query = schema.query.parse(req.query);
|
||||
}
|
||||
if (schema.params) {
|
||||
req.params = schema.params.parse(req.params);
|
||||
}
|
||||
return next();
|
||||
} catch (error) {
|
||||
if ((error as ZodError).name === 'ZodError') {
|
||||
return next(error);
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
18
apps/backend/src/models/budget.model.ts
Normal file
18
apps/backend/src/models/budget.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const budgetSchema = new mongoose.Schema(
|
||||
{
|
||||
category: { type: String, required: true },
|
||||
amount: { type: Number, required: true, min: 0 },
|
||||
currency: { type: String, default: 'CNY' },
|
||||
period: { type: String, enum: ['monthly', 'weekly'], default: 'monthly' },
|
||||
threshold: { type: Number, min: 0, max: 1, default: 0.8 },
|
||||
usage: { type: Number, min: 0, default: 0 },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type BudgetDocument = InferSchemaType<typeof budgetSchema>;
|
||||
|
||||
export const BudgetModel = mongoose.models.Budget ?? mongoose.model('Budget', budgetSchema);
|
||||
22
apps/backend/src/models/transaction.model.ts
Normal file
22
apps/backend/src/models/transaction.model.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const transactionSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, required: true },
|
||||
amount: { type: Number, required: true, min: 0 },
|
||||
currency: { type: String, default: 'CNY' },
|
||||
category: { type: String, required: true },
|
||||
type: { type: String, enum: ['expense', 'income'], required: true },
|
||||
source: { type: String, enum: ['manual', 'notification', 'ocr', 'ai'], default: 'manual' },
|
||||
status: { type: String, enum: ['pending', 'confirmed', 'rejected'], default: 'confirmed' },
|
||||
occurredAt: { type: Date, required: true },
|
||||
notes: { type: String },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type TransactionDocument = InferSchemaType<typeof transactionSchema>;
|
||||
|
||||
export const TransactionModel = mongoose.models.Transaction ?? mongoose.model('Transaction', transactionSchema);
|
||||
17
apps/backend/src/models/user.model.ts
Normal file
17
apps/backend/src/models/user.model.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
email: { type: String, required: true, unique: true, index: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
displayName: { type: String, required: true },
|
||||
avatarUrl: { type: String },
|
||||
preferredCurrency: { type: String, default: 'CNY' },
|
||||
notificationPermissionGranted: { type: Boolean, default: false }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type UserDocument = InferSchemaType<typeof userSchema>;
|
||||
|
||||
export const UserModel = mongoose.models.User ?? mongoose.model('User', userSchema);
|
||||
14
apps/backend/src/modules/analysis/analysis.controller.ts
Normal file
14
apps/backend/src/modules/analysis/analysis.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { buildSpendingInsights, estimateCalories } from './analysis.service.js';
|
||||
|
||||
export const getSpendingInsights = async (req: Request, res: Response) => {
|
||||
const range = (req.query.range as '30d' | '90d') ?? '30d';
|
||||
const data = await buildSpendingInsights(range);
|
||||
return res.json(data);
|
||||
};
|
||||
|
||||
export const estimateCaloriesHandler = (req: Request, res: Response) => {
|
||||
const { query } = req.body as { query: string };
|
||||
const data = estimateCalories(query);
|
||||
return res.json(data);
|
||||
};
|
||||
11
apps/backend/src/modules/analysis/analysis.router.ts
Normal file
11
apps/backend/src/modules/analysis/analysis.router.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { analysisQuerySchema, calorieRequestSchema } from './analysis.schema.js';
|
||||
import { getSpendingInsights, estimateCaloriesHandler } from './analysis.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights);
|
||||
router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler);
|
||||
|
||||
export default router;
|
||||
9
apps/backend/src/modules/analysis/analysis.schema.ts
Normal file
9
apps/backend/src/modules/analysis/analysis.schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const calorieRequestSchema = z.object({
|
||||
query: z.string().min(2)
|
||||
});
|
||||
|
||||
export const analysisQuerySchema = z.object({
|
||||
range: z.enum(['30d', '90d']).default('30d')
|
||||
});
|
||||
131
apps/backend/src/modules/analysis/analysis.service.ts
Normal file
131
apps/backend/src/modules/analysis/analysis.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TransactionDto } from '../transactions/transactions.service.js';
|
||||
import { listTransactions } from '../transactions/transactions.service.js';
|
||||
|
||||
const DAY_IN_MS = 86_400_000;
|
||||
|
||||
interface CategoryInsight {
|
||||
name: string;
|
||||
amount: number;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
}
|
||||
|
||||
export interface SpendingInsightResult {
|
||||
range: '30d' | '90d';
|
||||
summary: string;
|
||||
recommendations: string[];
|
||||
categories: CategoryInsight[];
|
||||
}
|
||||
|
||||
const sumExpenses = (transactions: TransactionDto[]) =>
|
||||
transactions.filter((txn) => txn.type === 'expense').reduce((total, txn) => total + txn.amount, 0);
|
||||
|
||||
const groupByCategory = (transactions: TransactionDto[]) => {
|
||||
const map = new Map<string, number>();
|
||||
transactions
|
||||
.filter((txn) => txn.type === 'expense')
|
||||
.forEach((txn) => {
|
||||
map.set(txn.category, (map.get(txn.category) ?? 0) + txn.amount);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const detectTrend = (current: number, previous: number): 'up' | 'down' | 'flat' => {
|
||||
if (previous === 0 && current === 0) return 'flat';
|
||||
if (previous === 0) return 'up';
|
||||
const ratio = current / previous;
|
||||
if (ratio > 1.1) return 'up';
|
||||
if (ratio < 0.9) return 'down';
|
||||
return 'flat';
|
||||
};
|
||||
|
||||
const buildRecommendations = (topCategories: CategoryInsight[], totalExpense: number) => {
|
||||
if (topCategories.length === 0 || totalExpense === 0) {
|
||||
return ['支出较低,保持良好的消费习惯。'];
|
||||
}
|
||||
|
||||
return topCategories.slice(0, 3).map((category) => {
|
||||
const ratio = (category.amount / totalExpense) * 100;
|
||||
const roundedRatio = Math.round(ratio);
|
||||
return `「${category.name}」支出占比约 ${roundedRatio}% 。试着为该分类设置日常预算或使用优惠券降低开销。`;
|
||||
});
|
||||
};
|
||||
|
||||
export async function buildSpendingInsights(range: '30d' | '90d' = '30d'): Promise<SpendingInsightResult> {
|
||||
const transactions = await listTransactions();
|
||||
const now = Date.now();
|
||||
const windowDays = range === '30d' ? 30 : 90;
|
||||
const windowStart = now - windowDays * DAY_IN_MS;
|
||||
const previousWindowStart = windowStart - windowDays * DAY_IN_MS;
|
||||
|
||||
const currentPeriod = transactions.filter((txn) => new Date(txn.occurredAt).getTime() >= windowStart);
|
||||
const previousPeriod = transactions.filter((txn) => {
|
||||
const time = new Date(txn.occurredAt).getTime();
|
||||
return time >= previousWindowStart && time < windowStart;
|
||||
});
|
||||
|
||||
const totalExpense = sumExpenses(currentPeriod);
|
||||
const previousExpense = sumExpenses(previousPeriod);
|
||||
|
||||
const currentByCategory = groupByCategory(currentPeriod);
|
||||
const previousByCategory = groupByCategory(previousPeriod);
|
||||
|
||||
const categories: CategoryInsight[] = [...currentByCategory.entries()]
|
||||
.sort(([, amountA], [, amountB]) => amountB - amountA)
|
||||
.slice(0, 5)
|
||||
.map(([name, amount]) => ({
|
||||
name,
|
||||
amount: Number(amount.toFixed(2)),
|
||||
trend: detectTrend(amount, previousByCategory.get(name) ?? 0)
|
||||
}));
|
||||
|
||||
const delta = totalExpense - previousExpense;
|
||||
const summary = previousExpense === 0
|
||||
? `本期总支出约 ¥${totalExpense.toFixed(2)}。`
|
||||
: delta >= 0
|
||||
? `本期总支出约 ¥${totalExpense.toFixed(2)},相比上一周期增加 ¥${Math.abs(delta).toFixed(2)}。`
|
||||
: `本期总支出约 ¥${totalExpense.toFixed(2)},相比上一周期减少 ¥${Math.abs(delta).toFixed(2)}。`;
|
||||
|
||||
const recommendations = buildRecommendations(categories, totalExpense);
|
||||
|
||||
return {
|
||||
range,
|
||||
summary,
|
||||
recommendations,
|
||||
categories
|
||||
};
|
||||
}
|
||||
|
||||
const kcalDictionary: Record<string, { calories: number; tips: string[] }> = {
|
||||
'麦辣鸡腿堡': {
|
||||
calories: 650,
|
||||
tips: ['搭配无糖饮品可减少额外糖分。', '可选择去皮鸡腿以减少脂肪摄入。']
|
||||
},
|
||||
'星冰乐': {
|
||||
calories: 420,
|
||||
tips: ['选择少糖或去奶油版本可以减少热量约 20%。']
|
||||
},
|
||||
'沙拉': {
|
||||
calories: 180,
|
||||
tips: ['补充蛋白质可增加饱腹感,例如鸡胸肉或豆腐。']
|
||||
}
|
||||
};
|
||||
|
||||
export function estimateCalories(query: string) {
|
||||
const normalized = query.trim();
|
||||
const match = Object.entries(kcalDictionary).find(([keyword]) => normalized.includes(keyword));
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
query,
|
||||
calories: 480,
|
||||
insights: ['未匹配到具体食物,以上为估算值。建议减少含糖饮料摄入,控制每日总热量。']
|
||||
};
|
||||
}
|
||||
|
||||
const [, info] = match;
|
||||
return {
|
||||
query,
|
||||
calories: info.calories,
|
||||
insights: info.tips
|
||||
};
|
||||
}
|
||||
62
apps/backend/src/modules/auth/auth.controller.ts
Normal file
62
apps/backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
refreshSession,
|
||||
requestPasswordReset,
|
||||
revokeRefreshToken
|
||||
} from './auth.service.js';
|
||||
import type { LoginInput, RegisterInput } from './auth.schema.js';
|
||||
|
||||
export const register = async (req: Request, res: Response) => {
|
||||
const payload = req.body as RegisterInput;
|
||||
const { email, password, displayName, preferredCurrency } = payload;
|
||||
|
||||
try {
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
preferredCurrency: preferredCurrency ?? 'CNY'
|
||||
});
|
||||
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
|
||||
logger.warn({ error }, 'Registration failed');
|
||||
return res.status(statusCode).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
const payload = req.body as LoginInput;
|
||||
try {
|
||||
const result = await authenticateUser(payload);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
|
||||
logger.warn({ error }, 'Login failed');
|
||||
return res.status(statusCode).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const refresh = async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
const result = await refreshSession(refreshToken);
|
||||
return res.json(result);
|
||||
};
|
||||
|
||||
export const forgotPassword = async (req: Request, res: Response) => {
|
||||
const { email } = req.body as { email: string };
|
||||
await requestPasswordReset(email);
|
||||
return res.json({ message: 'If the account exists, a reset link has been emailed.' });
|
||||
};
|
||||
|
||||
export const logout = async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
if (refreshToken) {
|
||||
await revokeRefreshToken(refreshToken);
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
14
apps/backend/src/modules/auth/auth.router.ts
Normal file
14
apps/backend/src/modules/auth/auth.router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { registerSchema, loginSchema, refreshSchema, forgotPasswordSchema } from './auth.schema.js';
|
||||
import { register, login, refresh, forgotPassword, logout } from './auth.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', validateRequest({ body: registerSchema }), register);
|
||||
router.post('/login', validateRequest({ body: loginSchema }), login);
|
||||
router.post('/refresh', validateRequest({ body: refreshSchema }), refresh);
|
||||
router.post('/forgot-password', validateRequest({ body: forgotPasswordSchema }), forgotPassword);
|
||||
router.post('/logout', logout);
|
||||
|
||||
export default router;
|
||||
33
apps/backend/src/modules/auth/auth.schema.ts
Normal file
33
apps/backend/src/modules/auth/auth.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.regex(/[A-Z]/, 'Password must include an uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must include a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must include a special character'),
|
||||
confirmPassword: z.string().min(8),
|
||||
displayName: z.string().min(1, 'Display name is required'),
|
||||
preferredCurrency: z.string().default('CNY')
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword']
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(10)
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
229
apps/backend/src/modules/auth/auth.service.ts
Normal file
229
apps/backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import mongoose from 'mongoose';
|
||||
import { env } from '../../config/env.js';
|
||||
import { UserModel, type UserDocument } from '../../models/user.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
|
||||
const ACCESS_TOKEN_TTL = '15m';
|
||||
const REFRESH_TOKEN_TTL = '7d';
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
preferredCurrency: string;
|
||||
notificationPermissionGranted: boolean;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
refreshExpiresIn: number;
|
||||
}
|
||||
|
||||
const memoryUsers = new Map<string, AuthUser>();
|
||||
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | AuthUser): AuthUser => {
|
||||
if ('passwordHash' in user && 'id' in user) {
|
||||
return user as AuthUser;
|
||||
}
|
||||
|
||||
const doc = user as UserDocument & { _id: mongoose.Types.ObjectId };
|
||||
return {
|
||||
id: doc._id.toString(),
|
||||
email: doc.email,
|
||||
displayName: doc.displayName,
|
||||
passwordHash: doc.passwordHash,
|
||||
preferredCurrency: doc.preferredCurrency,
|
||||
notificationPermissionGranted: doc.notificationPermissionGranted ?? false,
|
||||
avatarUrl: doc.avatarUrl ?? undefined
|
||||
};
|
||||
};
|
||||
|
||||
const seedUsers = async () => {
|
||||
if (memoryUsers.size > 0) return;
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
const user: AuthUser = {
|
||||
id: createId(),
|
||||
email: 'user@example.com',
|
||||
displayName: '示例用户',
|
||||
passwordHash,
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
};
|
||||
|
||||
memoryUsers.set(user.email, user);
|
||||
};
|
||||
|
||||
void seedUsers();
|
||||
|
||||
const getUserByEmail = async (email: string): Promise<AuthUser | null> => {
|
||||
if (isDatabaseReady()) {
|
||||
const doc = await UserModel.findOne({ email }).lean<UserDocument & { _id: mongoose.Types.ObjectId }>();
|
||||
return doc ? toAuthUser(doc) : null;
|
||||
}
|
||||
|
||||
return memoryUsers.get(email) ?? null;
|
||||
};
|
||||
|
||||
const saveUser = async (user: AuthUser): Promise<AuthUser> => {
|
||||
if (isDatabaseReady()) {
|
||||
const created = await UserModel.create({
|
||||
email: user.email,
|
||||
passwordHash: user.passwordHash,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
preferredCurrency: user.preferredCurrency,
|
||||
notificationPermissionGranted: user.notificationPermissionGranted
|
||||
});
|
||||
return toAuthUser(created);
|
||||
}
|
||||
|
||||
memoryUsers.set(user.email, user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const updateUserPassword = async (email: string, passwordHash: string) => {
|
||||
if (isDatabaseReady()) {
|
||||
await UserModel.updateOne({ email }, { passwordHash });
|
||||
return;
|
||||
}
|
||||
const existing = memoryUsers.get(email);
|
||||
if (existing) {
|
||||
memoryUsers.set(email, { ...existing, passwordHash });
|
||||
}
|
||||
};
|
||||
|
||||
const signTokens = (user: AuthUser): AuthTokens => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName
|
||||
},
|
||||
env.JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_TTL }
|
||||
);
|
||||
|
||||
const refreshTokenSecret = env.JWT_REFRESH_SECRET ?? env.JWT_SECRET;
|
||||
const refreshToken = jwt.sign(
|
||||
{
|
||||
sub: user.id
|
||||
},
|
||||
refreshTokenSecret,
|
||||
{ expiresIn: REFRESH_TOKEN_TTL }
|
||||
);
|
||||
|
||||
const refreshExpiresIn = jwt.decode(refreshToken, { json: true })?.exp ?? nowSeconds + 7 * 24 * 60 * 60;
|
||||
refreshTokenStore.set(refreshToken, { userId: user.id, expiresAt: new Date(refreshExpiresIn * 1000) });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: nowSeconds + 15 * 60,
|
||||
refreshExpiresIn
|
||||
};
|
||||
};
|
||||
|
||||
const profileFromUser = (user: AuthUser) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
preferredCurrency: user.preferredCurrency,
|
||||
notificationPermissionGranted: user.notificationPermissionGranted
|
||||
});
|
||||
|
||||
export async function registerUser(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
preferredCurrency: string;
|
||||
}) {
|
||||
const existing = await getUserByEmail(input.email);
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||
const user: AuthUser = {
|
||||
id: createId(),
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
preferredCurrency: input.preferredCurrency,
|
||||
notificationPermissionGranted: false,
|
||||
passwordHash
|
||||
};
|
||||
|
||||
const saved = await saveUser(user);
|
||||
const tokens = signTokens(saved);
|
||||
return { user: profileFromUser(saved), tokens };
|
||||
}
|
||||
|
||||
export async function authenticateUser(input: { email: string; password: string }) {
|
||||
const user = await getUserByEmail(input.email);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(input.password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
}
|
||||
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
}
|
||||
|
||||
export async function refreshSession(refreshToken: string) {
|
||||
const refreshTokenSecret = env.JWT_REFRESH_SECRET ?? env.JWT_SECRET;
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, refreshTokenSecret) as { sub: string };
|
||||
const stored = refreshTokenStore.get(refreshToken);
|
||||
|
||||
if (!stored || stored.userId !== payload.sub) {
|
||||
throw new Error('Refresh token revoked');
|
||||
}
|
||||
|
||||
const userDoc = isDatabaseReady()
|
||||
? await UserModel.findById(payload.sub).lean<UserDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
: [...memoryUsers.values()].find((user) => user.id === payload.sub) ?? null;
|
||||
|
||||
if (!userDoc) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const user = toAuthUser(userDoc as AuthUser);
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to refresh session');
|
||||
throw Object.assign(new Error('Invalid refresh token'), { statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(refreshToken: string) {
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string) {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempPassword = Math.random().toString(36).slice(-10);
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10);
|
||||
await updateUserPassword(email, passwordHash);
|
||||
logger.info({ email }, 'Password reset token generated (simulated)');
|
||||
}
|
||||
31
apps/backend/src/modules/budgets/budgets.controller.ts
Normal file
31
apps/backend/src/modules/budgets/budgets.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export const listBudgetsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listBudgets();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createBudgetHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as CreateBudgetInput;
|
||||
const budget = await createBudget(payload);
|
||||
return res.status(201).json({ data: budget });
|
||||
};
|
||||
|
||||
export const updateBudgetHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as UpdateBudgetInput;
|
||||
const budget = await updateBudget(req.params.id, payload);
|
||||
if (!budget) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
return res.json({ data: budget });
|
||||
};
|
||||
|
||||
export const deleteBudgetHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteBudget(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
14
apps/backend/src/modules/budgets/budgets.router.ts
Normal file
14
apps/backend/src/modules/budgets/budgets.router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { createBudgetSchema, updateBudgetSchema } from './budgets.schema.js';
|
||||
import { listBudgetsHandler, createBudgetHandler, updateBudgetHandler, deleteBudgetHandler } from './budgets.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', listBudgetsHandler);
|
||||
router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler);
|
||||
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler);
|
||||
router.delete('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), deleteBudgetHandler);
|
||||
|
||||
export default router;
|
||||
16
apps/backend/src/modules/budgets/budgets.schema.ts
Normal file
16
apps/backend/src/modules/budgets/budgets.schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createBudgetSchema = z.object({
|
||||
category: z.string().min(1),
|
||||
amount: z.coerce.number().positive(),
|
||||
currency: z.string().length(3).default('CNY'),
|
||||
period: z.enum(['monthly', 'weekly']).default('monthly'),
|
||||
threshold: z.coerce.number().min(0).max(1).default(0.8),
|
||||
usage: z.coerce.number().min(0).optional(),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export const updateBudgetSchema = createBudgetSchema.partial();
|
||||
|
||||
export type CreateBudgetInput = z.infer<typeof createBudgetSchema>;
|
||||
export type UpdateBudgetInput = z.infer<typeof updateBudgetSchema>;
|
||||
159
apps/backend/src/modules/budgets/budgets.service.ts
Normal file
159
apps/backend/src/modules/budgets/budgets.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export interface BudgetDto {
|
||||
id: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: 'monthly' | 'weekly';
|
||||
threshold: number;
|
||||
usage: number;
|
||||
userId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MemoryBudget extends BudgetDto {}
|
||||
|
||||
const memoryBudgets: MemoryBudget[] = [];
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): BudgetDto => {
|
||||
const id = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
|
||||
|
||||
return {
|
||||
id,
|
||||
category: doc.category,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
period: doc.period,
|
||||
threshold: doc.threshold,
|
||||
usage: doc.usage ?? 0,
|
||||
userId: doc.userId ? doc.userId.toString() : undefined,
|
||||
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
|
||||
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
const seedBudgets = () => {
|
||||
if (memoryBudgets.length > 0) return;
|
||||
const now = new Date().toISOString();
|
||||
memoryBudgets.push(
|
||||
{
|
||||
id: createId(),
|
||||
category: '餐饮',
|
||||
amount: 2000,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.8,
|
||||
usage: 1250,
|
||||
userId: 'demo-user',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
category: '交通',
|
||||
amount: 600,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.75,
|
||||
usage: 320,
|
||||
userId: 'demo-user',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
seedBudgets();
|
||||
|
||||
export async function listBudgets(): Promise<BudgetDto[]> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const docs = await BudgetModel.find({ userId }).lean().exec();
|
||||
return (docs as Array<BudgetDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
||||
}
|
||||
|
||||
return [...memoryBudgets].sort((a, b) => a.category.localeCompare(b.category));
|
||||
}
|
||||
|
||||
export async function createBudget(payload: CreateBudgetInput): Promise<BudgetDto> {
|
||||
const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 };
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await BudgetModel.create({ ...payload, ...defaults, userId });
|
||||
return toDto(created);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const budget: MemoryBudget = {
|
||||
id: createId(),
|
||||
category: payload.category,
|
||||
amount: payload.amount,
|
||||
currency: defaults.currency,
|
||||
period: payload.period,
|
||||
threshold: payload.threshold,
|
||||
usage: defaults.usage ?? 0,
|
||||
userId: payload.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
memoryBudgets.push(budget);
|
||||
return budget;
|
||||
}
|
||||
|
||||
export async function updateBudget(id: string, payload: UpdateBudgetInput): Promise<BudgetDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const updated = await BudgetModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{ ...payload },
|
||||
{ new: true }
|
||||
);
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = memoryBudgets[index];
|
||||
const updated: MemoryBudget = {
|
||||
...existing,
|
||||
...payload,
|
||||
amount: payload.amount ?? existing.amount,
|
||||
usage: payload.usage ?? existing.usage,
|
||||
threshold: payload.threshold ?? existing.threshold,
|
||||
period: payload.period ?? existing.period,
|
||||
currency: payload.currency ?? existing.currency,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
memoryBudgets[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteBudget(id: string): Promise<boolean> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const result = await BudgetModel.findOneAndDelete({ _id: id, userId });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memoryBudgets.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
import { TransactionModel } from '../../models/transaction.model.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
|
||||
const maskSecret = (secret?: string | null) => {
|
||||
if (!secret) return null;
|
||||
if (secret.length <= 6) return '*'.repeat(secret.length);
|
||||
return `${secret.slice(0, 3)}***${secret.slice(-3)}`;
|
||||
};
|
||||
|
||||
export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||
const userId = await getDefaultUserId();
|
||||
const filter = { userId, source: 'notification' as const };
|
||||
const count = await TransactionModel.countDocuments(filter);
|
||||
const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as
|
||||
| { createdAt?: Date }
|
||||
| null;
|
||||
const ingestEndpoint = `${req.protocol}://${req.get('host') ?? 'localhost'}/api/transactions/notification`;
|
||||
|
||||
const secret = env.NOTIFICATION_WEBHOOK_SECRET;
|
||||
const response = {
|
||||
secretConfigured: Boolean(secret),
|
||||
secretHint: maskSecret(secret),
|
||||
webhookSecret: env.isProduction ? null : secret ?? null,
|
||||
packageWhitelist: env.notificationPackageWhitelist,
|
||||
ingestedCount: count,
|
||||
lastNotificationAt: latest?.createdAt ? new Date(latest.createdAt).toISOString() : null,
|
||||
ingestEndpoint
|
||||
};
|
||||
|
||||
return res.json(response);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getNotificationStatus } from './notifications.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/status', getNotificationStatus);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { env } from '../../config/env.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import {
|
||||
listTransactions,
|
||||
createTransaction,
|
||||
getTransactionById,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
ingestNotification
|
||||
} from './transactions.service.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export const listTransactionsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listTransactions();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as CreateTransactionInput;
|
||||
const transaction = await createTransaction(payload);
|
||||
return res.status(201).json({ data: transaction });
|
||||
};
|
||||
|
||||
export const getTransactionHandler = async (req: Request, res: Response) => {
|
||||
const transaction = await getTransactionById(req.params.id);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const updateTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as UpdateTransactionInput;
|
||||
const transaction = await updateTransaction(req.params.id, payload);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const deleteTransactionHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteTransaction(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
const verifySignature = (signature: string, parts: string[]): boolean => {
|
||||
if (!env.NOTIFICATION_WEBHOOK_SECRET) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = parts.join('|');
|
||||
const computed = createHmac('sha256', env.NOTIFICATION_WEBHOOK_SECRET).update(payload).digest('hex');
|
||||
return computed === signature;
|
||||
};
|
||||
|
||||
export const createTransactionFromNotificationHandler = async (req: Request, res: Response) => {
|
||||
const { packageName, title, body, receivedAt, signature } = req.body as {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const payloadParts = [packageName, title, body, new Date(receivedAt).getTime().toString()];
|
||||
if (!verifySignature(signature, payloadParts)) {
|
||||
logger.warn({ packageName }, 'Notification signature mismatch');
|
||||
return res.status(401).json({ message: 'Invalid notification signature' });
|
||||
}
|
||||
|
||||
const transaction = await ingestNotification({
|
||||
packageName,
|
||||
title,
|
||||
body,
|
||||
receivedAt: new Date(receivedAt)
|
||||
});
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Notification ingested and awaiting confirmation',
|
||||
data: transaction
|
||||
});
|
||||
};
|
||||
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import {
|
||||
createTransactionSchema,
|
||||
updateTransactionSchema,
|
||||
transactionIdSchema,
|
||||
notificationSchema
|
||||
} from './transactions.schema.js';
|
||||
import {
|
||||
listTransactionsHandler,
|
||||
createTransactionHandler,
|
||||
getTransactionHandler,
|
||||
updateTransactionHandler,
|
||||
deleteTransactionHandler,
|
||||
createTransactionFromNotificationHandler
|
||||
} from './transactions.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', listTransactionsHandler);
|
||||
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
|
||||
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
||||
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
||||
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
||||
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
||||
|
||||
export default router;
|
||||
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseTransactionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
amount: z.coerce.number().min(0),
|
||||
currency: z.string().length(3).default('CNY'),
|
||||
category: z.string().min(1),
|
||||
type: z.enum(['expense', 'income']),
|
||||
source: z.enum(['manual', 'notification', 'ocr', 'ai']).default('manual'),
|
||||
status: z.enum(['pending', 'confirmed', 'rejected']).default('confirmed'),
|
||||
occurredAt: z.coerce.date(),
|
||||
notes: z.string().max(300).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export const createTransactionSchema = baseTransactionSchema;
|
||||
|
||||
export const updateTransactionSchema = baseTransactionSchema.partial();
|
||||
|
||||
export const transactionIdSchema = z.object({
|
||||
id: z.string().min(1)
|
||||
});
|
||||
|
||||
export const notificationSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
receivedAt: z.coerce.date(),
|
||||
signature: z.string().min(32),
|
||||
extras: z.record(z.any()).optional()
|
||||
});
|
||||
|
||||
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
|
||||
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
|
||||
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export interface TransactionDto {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
type: 'expense' | 'income';
|
||||
source: 'manual' | 'notification' | 'ocr' | 'ai';
|
||||
status: 'pending' | 'confirmed' | 'rejected';
|
||||
occurredAt: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MemoryTransaction extends TransactionDto {}
|
||||
|
||||
const memoryTransactions: MemoryTransaction[] = [];
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): TransactionDto => {
|
||||
const rawId = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
|
||||
|
||||
return {
|
||||
id: rawId,
|
||||
title: doc.title,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
category: doc.category,
|
||||
type: doc.type,
|
||||
source: doc.source,
|
||||
status: doc.status,
|
||||
occurredAt: new Date(doc.occurredAt).toISOString(),
|
||||
notes: doc.notes,
|
||||
metadata: doc.metadata ?? undefined,
|
||||
userId: doc.userId ? doc.userId.toString() : undefined,
|
||||
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
|
||||
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
const seedMemoryTransactions = () => {
|
||||
if (memoryTransactions.length > 0) return;
|
||||
const now = new Date();
|
||||
memoryTransactions.push(
|
||||
{
|
||||
id: createId(),
|
||||
title: '星巴克咖啡',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'confirmed',
|
||||
occurredAt: now.toISOString(),
|
||||
notes: '餐饮 | 早餐',
|
||||
metadata: { packageName: 'com.eg.android.AlipayGphone' },
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '地铁充值',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
status: 'confirmed',
|
||||
occurredAt: new Date(now.getTime() - 86_400_000).toISOString(),
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '午餐报销',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
status: 'pending',
|
||||
occurredAt: new Date(now.getTime() - 172_800_000).toISOString(),
|
||||
notes: '审批中',
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
seedMemoryTransactions();
|
||||
|
||||
export async function listTransactions(): Promise<TransactionDto[]> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const docs = await TransactionModel.find({ userId })
|
||||
.sort({ occurredAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return (docs as Array<TransactionDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
||||
}
|
||||
return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1));
|
||||
}
|
||||
|
||||
export async function getTransactionById(id: string): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec();
|
||||
return doc ? toDto(doc as TransactionDocument & { _id: mongoose.Types.ObjectId }) : null;
|
||||
}
|
||||
return memoryTransactions.find((txn) => txn.id === id) ?? null;
|
||||
}
|
||||
|
||||
export async function createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
|
||||
const normalizedAmount = Math.abs(payload.amount);
|
||||
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await TransactionModel.create({
|
||||
...payload,
|
||||
source: payload.source ?? 'manual',
|
||||
currency: payload.currency ?? 'CNY',
|
||||
amount: normalizedAmount,
|
||||
occurredAt: occuredDate,
|
||||
userId
|
||||
});
|
||||
return toDto(created);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const transaction: MemoryTransaction = {
|
||||
id: createId(),
|
||||
title: payload.title,
|
||||
amount: normalizedAmount,
|
||||
currency: payload.currency ?? 'CNY',
|
||||
category: payload.category,
|
||||
type: payload.type,
|
||||
source: payload.source ?? 'manual',
|
||||
status: payload.status,
|
||||
occurredAt: occuredDate.toISOString(),
|
||||
notes: payload.notes,
|
||||
metadata: payload.metadata,
|
||||
userId: payload.userId,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
};
|
||||
|
||||
memoryTransactions.unshift(transaction);
|
||||
logger.debug({ id: transaction.id }, 'Transaction created (memory)');
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export async function updateTransaction(id: string, payload: UpdateTransactionInput): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const updated = await TransactionModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{
|
||||
...payload,
|
||||
currency: payload.currency ?? undefined,
|
||||
source: payload.source ?? undefined,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : undefined,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt) : undefined
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = memoryTransactions[index];
|
||||
const updated: MemoryTransaction = {
|
||||
...existing,
|
||||
...payload,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
memoryTransactions[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string): Promise<boolean> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memoryTransactions.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
const now = payload.receivedAt;
|
||||
return createTransaction({
|
||||
title: payload.title,
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
category: '待分类',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'pending',
|
||||
occurredAt: now,
|
||||
notes: payload.body,
|
||||
metadata: { packageName: payload.packageName }
|
||||
});
|
||||
}
|
||||
20
apps/backend/src/routes/index.ts
Normal file
20
apps/backend/src/routes/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import authRouter from '../modules/auth/auth.router.js';
|
||||
import transactionsRouter from '../modules/transactions/transactions.router.js';
|
||||
import analysisRouter from '../modules/analysis/analysis.router.js';
|
||||
import budgetsRouter from '../modules/budgets/budgets.router.js';
|
||||
import notificationsRouter from '../modules/notifications/notifications.router.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
router.use('/auth', authRouter);
|
||||
router.use('/transactions', transactionsRouter);
|
||||
router.use('/analysis', analysisRouter);
|
||||
router.use('/budgets', budgetsRouter);
|
||||
router.use('/notifications', notificationsRouter);
|
||||
|
||||
export { router };
|
||||
107
apps/backend/src/scripts/seed.ts
Normal file
107
apps/backend/src/scripts/seed.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { connectToDatabase, disconnectFromDatabase } from '../config/database.js';
|
||||
import { logger } from '../config/logger.js';
|
||||
import { UserModel } from '../models/user.model.js';
|
||||
import { TransactionModel } from '../models/transaction.model.js';
|
||||
import { BudgetModel } from '../models/budget.model.js';
|
||||
|
||||
async function seed() {
|
||||
await connectToDatabase();
|
||||
|
||||
const [user] = await UserModel.find().limit(1);
|
||||
let targetUser = user;
|
||||
|
||||
if (!targetUser) {
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
targetUser = await UserModel.create({
|
||||
email: 'user@example.com',
|
||||
passwordHash,
|
||||
displayName: '示例用户',
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
});
|
||||
logger.info({ email: targetUser.email }, 'Created default user');
|
||||
}
|
||||
|
||||
const userId = targetUser._id;
|
||||
|
||||
const existingBudgets = await BudgetModel.countDocuments({ userId });
|
||||
if (existingBudgets === 0) {
|
||||
await BudgetModel.create([
|
||||
{
|
||||
category: '餐饮',
|
||||
amount: 2000,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.8,
|
||||
usage: 1250,
|
||||
userId
|
||||
},
|
||||
{
|
||||
category: '交通',
|
||||
amount: 600,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.75,
|
||||
usage: 320,
|
||||
userId
|
||||
}
|
||||
]);
|
||||
logger.info('Seeded default budgets');
|
||||
}
|
||||
|
||||
const existingTransactions = await TransactionModel.countDocuments({ userId });
|
||||
if (existingTransactions === 0) {
|
||||
const now = new Date();
|
||||
await TransactionModel.create([
|
||||
{
|
||||
title: '星巴克咖啡',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'confirmed',
|
||||
occurredAt: now,
|
||||
notes: '早餐咖啡',
|
||||
userId
|
||||
},
|
||||
{
|
||||
title: '地铁充值',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
status: 'confirmed',
|
||||
occurredAt: new Date(now.getTime() - 86_400_000),
|
||||
userId
|
||||
},
|
||||
{
|
||||
title: '午餐报销',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
status: 'pending',
|
||||
occurredAt: new Date(now.getTime() - 172_800_000),
|
||||
notes: '审批中',
|
||||
userId
|
||||
}
|
||||
]);
|
||||
logger.info('Seeded default transactions');
|
||||
}
|
||||
|
||||
logger.info('Database seed completed');
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((error) => {
|
||||
logger.error({ error }, 'Failed to seed database');
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await disconnectFromDatabase();
|
||||
});
|
||||
33
apps/backend/src/services/user-context.ts
Normal file
33
apps/backend/src/services/user-context.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { UserModel } from '../models/user.model.js';
|
||||
import { logger } from '../config/logger.js';
|
||||
|
||||
let cachedUserId: string | null = null;
|
||||
|
||||
export async function getDefaultUserId(): Promise<string> {
|
||||
if (cachedUserId) {
|
||||
return cachedUserId;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOne();
|
||||
|
||||
if (!user) {
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
user = await UserModel.create({
|
||||
email: 'user@example.com',
|
||||
passwordHash,
|
||||
displayName: '示例用户',
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
});
|
||||
logger.info({ email: user.email }, 'Provisioned default user for context');
|
||||
}
|
||||
|
||||
const ensuredUser = user;
|
||||
if (!ensuredUser) {
|
||||
throw new Error('Unable to resolve default user');
|
||||
}
|
||||
|
||||
cachedUserId = ensuredUser._id.toString();
|
||||
return cachedUserId as string;
|
||||
}
|
||||
3
apps/backend/src/utils/id.ts
Normal file
3
apps/backend/src/utils/id.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const createId = () => randomUUID();
|
||||
17
apps/backend/tsconfig.json
Normal file
17
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
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()],
|
||||
})
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7.0.14
|
||||
platform: linux/amd64
|
||||
container_name: ai-bill-mongo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
volumes:
|
||||
mongo-data:
|
||||
driver: local
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ai-bill",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "pnpm@8.15.9",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel --filter frontend --filter backend dev",
|
||||
"dev:frontend": "pnpm --filter frontend dev",
|
||||
"dev:backend": "pnpm --filter backend dev",
|
||||
"build": "pnpm -r --stream build",
|
||||
"lint": "pnpm -r lint",
|
||||
"db:up": "docker compose up -d mongo",
|
||||
"db:down": "docker compose down"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
3678
pnpm-lock.yaml
generated
Normal file
3678
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
Reference in New Issue
Block a user