first commit

This commit is contained in:
2025-11-01 09:24:26 +08:00
commit 6516d8dc78
87 changed files with 7558 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
apps/frontend/dist
apps/backend/dist
*.local
.env
.DS_Store

75
README.md Normal file
View 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
View 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=

View 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
View 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
View 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;
}

View 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');
}
}

View 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;

View 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
View 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);
});

View 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
});
};

View File

@@ -0,0 +1,5 @@
import type { RequestHandler } from 'express';
export const notFoundHandler: RequestHandler = (_req, res) => {
res.status(404).json({ message: 'Resource not found' });
};

View 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);
}
};
};

View 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);

View 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);

View 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);

View 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);
};

View 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;

View 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')
});

View 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
};
}

View 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();
};

View 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;

View 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>;

View 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)');
}

View 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();
};

View 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;

View 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>;

View 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;
}

View File

@@ -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);
};

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { getNotificationStatus } from './notifications.controller.js';
const router = Router();
router.get('/status', getNotificationStatus);
export default router;

View File

@@ -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
});
};

View 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;

View 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>;

View 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 }
});
}

View 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 };

View 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();
});

View 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;
}

View File

@@ -0,0 +1,3 @@
import { randomUUID } from 'node:crypto';
export const createId = () => randomUUID();

View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
apps/frontend/README.md Normal file
View 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
View 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>

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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>

View 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;
}

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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;
}
});
}

View 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));
}
});
}

View 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
});
}

View 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));
}
});
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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
View 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');

View 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' }
]
};

View 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'
}
];

View 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'
}
];

View 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;

View 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;
}
}
});

View 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[];
}

View 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;
}

View 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;
}

View 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: []
};

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'apps/*'