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