chore: migrate to KT financial system
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
PORT=5320
|
||||
PORT=5666
|
||||
ACCESS_TOKEN_SECRET=access_token_secret
|
||||
REFRESH_TOKEN_SECRET=refresh_token_secret
|
||||
|
||||
@@ -13,3 +13,19 @@ $ pnpm run start
|
||||
# production mode
|
||||
$ pnpm run build
|
||||
```
|
||||
|
||||
## Telegram Webhook 集成
|
||||
|
||||
财务系统新增交易后可自动通知本地的 Telegram 机器人,默认会将交易数据通过以下 Webhook 发送:
|
||||
|
||||
- `http://192.168.9.28:8889/webhook/transaction`
|
||||
- 认证密钥:`ktapp.cc`
|
||||
|
||||
如需自定义目标地址或密钥,可在运行前设置以下环境变量:
|
||||
|
||||
```bash
|
||||
export TELEGRAM_WEBHOOK_URL="http://<bot-host>:8889/webhook/transaction"
|
||||
export TELEGRAM_WEBHOOK_SECRET="自定义密钥"
|
||||
```
|
||||
|
||||
也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL`、`FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。
|
||||
|
||||
29
apps/backend/api/finance/media.get.ts
Normal file
29
apps/backend/api/finance/media.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { fetchMediaMessages } from '~/utils/media-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event);
|
||||
const limit =
|
||||
typeof query.limit === 'string' && query.limit.length > 0
|
||||
? Number.parseInt(query.limit, 10)
|
||||
: undefined;
|
||||
const rawTypes = (query.types ?? query.type ?? query.fileType) as
|
||||
| string
|
||||
| undefined;
|
||||
const fileTypes = rawTypes
|
||||
? rawTypes
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
: undefined;
|
||||
|
||||
const messages = fetchMediaMessages({
|
||||
limit,
|
||||
fileTypes,
|
||||
});
|
||||
|
||||
return useResponseSuccess(messages);
|
||||
});
|
||||
|
||||
22
apps/backend/api/finance/media/[id].get.ts
Normal file
22
apps/backend/api/finance/media/[id].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
|
||||
if (!media) {
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(media);
|
||||
});
|
||||
|
||||
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { basename } from 'pathe';
|
||||
|
||||
import {
|
||||
getRouterParam,
|
||||
sendStream,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
setResponseStatus(event, 400);
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
if (!media) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
if (!media.filePath || !existsSync(media.filePath)) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('媒体文件不存在或已被移除', -1);
|
||||
}
|
||||
|
||||
const fileStats = statSync(media.filePath);
|
||||
|
||||
setResponseHeader(event, 'Content-Type', media.mimeType ?? 'application/octet-stream');
|
||||
setResponseHeader(
|
||||
event,
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(media.fileName ?? basename(media.filePath))}"`,
|
||||
);
|
||||
setResponseHeader(event, 'Content-Length', `${fileStats.size}`);
|
||||
|
||||
return sendStream(event, createReadStream(media.filePath));
|
||||
});
|
||||
|
||||
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getQuery } from 'h3';
|
||||
import {
|
||||
fetchTransactions,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const DEFAULT_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const includeDeleted = query.includeDeleted === 'true';
|
||||
const type = query.type as string | undefined;
|
||||
const rawStatuses = (query.statuses ?? query.status) as string | undefined;
|
||||
const statuses = rawStatuses
|
||||
? (rawStatuses
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0) as TransactionStatus[])
|
||||
: DEFAULT_STATUSES;
|
||||
|
||||
const reimbursements = fetchTransactions({
|
||||
includeDeleted,
|
||||
type,
|
||||
statuses,
|
||||
});
|
||||
|
||||
return useResponseSuccess(reimbursements);
|
||||
});
|
||||
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { readBody } from 'h3';
|
||||
import {
|
||||
createTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||
|
||||
const DEFAULT_CURRENCY = 'CNY';
|
||||
const DEFAULT_STATUS: TransactionStatus = 'pending';
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!body?.amount || !body?.transactionDate) {
|
||||
return useResponseError('缺少必填字段', -1);
|
||||
}
|
||||
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
|
||||
const type =
|
||||
(body.type as 'expense' | 'income' | 'transfer' | undefined) ?? 'expense';
|
||||
const status =
|
||||
(body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS;
|
||||
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
|
||||
const reimbursement = createTransaction({
|
||||
type,
|
||||
amount,
|
||||
currency: body.currency ?? DEFAULT_CURRENCY,
|
||||
categoryId: body.categoryId ?? null,
|
||||
accountId: body.accountId ?? null,
|
||||
transactionDate: body.transactionDate,
|
||||
description:
|
||||
body.description ??
|
||||
body.item ??
|
||||
(body.notes ? `${body.notes}` : '') ??
|
||||
'',
|
||||
project: body.project ?? body.category ?? null,
|
||||
memo: body.memo ?? body.notes ?? null,
|
||||
status,
|
||||
reimbursementBatch: body.reimbursementBatch ?? null,
|
||||
reviewNotes: body.reviewNotes ?? null,
|
||||
submittedBy: body.submittedBy ?? body.requester ?? null,
|
||||
approvedBy: body.approvedBy ?? null,
|
||||
approvedAt: body.approvedAt ?? null,
|
||||
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
|
||||
});
|
||||
|
||||
notifyTransactionWebhook(reimbursement, {
|
||||
action: 'reimbursement.created',
|
||||
}).catch((error) =>
|
||||
console.error(
|
||||
'[finance][reimbursements.post] webhook notify failed',
|
||||
error,
|
||||
),
|
||||
);
|
||||
|
||||
return useResponseSuccess(reimbursement);
|
||||
});
|
||||
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body?.isDeleted === false) {
|
||||
const restored = restoreTransaction(id);
|
||||
if (!restored) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
return useResponseSuccess(restored);
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (body?.type) payload.type = body.type;
|
||||
if (body?.amount !== undefined) {
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
payload.amount = amount;
|
||||
}
|
||||
if (body?.currency) payload.currency = body.currency;
|
||||
if (body?.categoryId !== undefined)
|
||||
payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||
if (body?.description !== undefined)
|
||||
payload.description = body.description ?? '';
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(updated);
|
||||
});
|
||||
@@ -1,11 +1,28 @@
|
||||
import { getQuery } from 'h3';
|
||||
import { fetchTransactions } from '~/utils/finance-repository';
|
||||
import {
|
||||
fetchTransactions,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const type = query.type as string | undefined;
|
||||
const transactions = fetchTransactions({ type });
|
||||
const includeDeleted = query.includeDeleted === 'true';
|
||||
const rawStatuses = (query.statuses ?? query.status) as
|
||||
| string
|
||||
| undefined;
|
||||
const statuses = rawStatuses
|
||||
? (rawStatuses
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0) as TransactionStatus[])
|
||||
: (['approved', 'paid'] satisfies TransactionStatus[]);
|
||||
const transactions = fetchTransactions({
|
||||
type,
|
||||
includeDeleted,
|
||||
statuses,
|
||||
});
|
||||
|
||||
return useResponseSuccess(transactions);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { readBody } from 'h3';
|
||||
import { createTransaction } from '~/utils/finance-repository';
|
||||
import {
|
||||
createTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||
|
||||
const DEFAULT_CURRENCY = 'CNY';
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
@@ -16,6 +27,12 @@ export default defineEventHandler(async (event) => {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
|
||||
const status =
|
||||
(body.status as TransactionStatus | undefined) ?? 'approved';
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
|
||||
const transaction = createTransaction({
|
||||
type: body.type,
|
||||
amount,
|
||||
@@ -26,7 +43,18 @@ export default defineEventHandler(async (event) => {
|
||||
description: body.description ?? '',
|
||||
project: body.project ?? null,
|
||||
memo: body.memo ?? null,
|
||||
status,
|
||||
reimbursementBatch: body.reimbursementBatch ?? null,
|
||||
reviewNotes: body.reviewNotes ?? null,
|
||||
submittedBy: body.submittedBy ?? null,
|
||||
approvedBy: body.approvedBy ?? null,
|
||||
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
|
||||
approvedAt: body.approvedAt ?? undefined,
|
||||
});
|
||||
|
||||
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
|
||||
console.error('[finance][transactions.post] webhook notify failed', error),
|
||||
);
|
||||
|
||||
return useResponseSuccess(transaction);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,18 @@ import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
@@ -41,6 +50,31 @@ export default defineEventHandler(async (event) => {
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
|
||||
BIN
apps/backend/backend.tar.gz
Normal file
BIN
apps/backend/backend.tar.gz
Normal file
Binary file not shown.
@@ -42,6 +42,24 @@ fs.mkdirSync(storeDir, { recursive: true });
|
||||
const dbFile = path.join(storeDir, 'finance.db');
|
||||
const db = new Database(dbFile);
|
||||
|
||||
function assertIdentifier(name) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||||
throw new Error(`Invalid identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const safeTable = assertIdentifier(table);
|
||||
const safeColumn = assertIdentifier(column);
|
||||
const columns = db
|
||||
.prepare(`PRAGMA table_info(${safeTable})`)
|
||||
.all()
|
||||
.map((item) => item.name);
|
||||
if (!columns.includes(safeColumn)) {
|
||||
db.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
|
||||
}
|
||||
}
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
@@ -106,11 +124,38 @@ db.exec(`
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'approved',
|
||||
status_updated_at TEXT,
|
||||
reimbursement_batch TEXT,
|
||||
review_notes TEXT,
|
||||
submitted_by TEXT,
|
||||
approved_by TEXT,
|
||||
approved_at TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status',
|
||||
"status TEXT NOT NULL DEFAULT 'approved'",
|
||||
);
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status_updated_at',
|
||||
'status_updated_at TEXT',
|
||||
);
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'reimbursement_batch',
|
||||
'reimbursement_batch TEXT',
|
||||
);
|
||||
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
|
||||
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
|
||||
|
||||
const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
|
||||
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length <= 1) {
|
||||
|
||||
@@ -5,13 +5,39 @@ import {
|
||||
MOCK_CURRENCIES,
|
||||
MOCK_EXCHANGE_RATES,
|
||||
} from './mock-data';
|
||||
import db from './sqlite';
|
||||
|
||||
export function listAccounts() {
|
||||
return MOCK_ACCOUNTS;
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return MOCK_CATEGORIES;
|
||||
// 从数据库读取分类
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, name, type, icon, color, user_id as userId, is_active as isActive
|
||||
FROM finance_categories
|
||||
WHERE is_active = 1
|
||||
ORDER BY type, id
|
||||
`);
|
||||
const categories = stmt.all() as any[];
|
||||
|
||||
// 转换为前端需要的格式
|
||||
return categories.map(cat => ({
|
||||
id: cat.id,
|
||||
userId: cat.userId,
|
||||
name: cat.name,
|
||||
type: cat.type,
|
||||
icon: cat.icon,
|
||||
color: cat.color,
|
||||
sortOrder: cat.id,
|
||||
isSystem: true,
|
||||
isActive: Boolean(cat.isActive),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('从数据库读取分类失败,使用MOCK数据:', error);
|
||||
return MOCK_CATEGORIES;
|
||||
}
|
||||
}
|
||||
|
||||
export function listBudgets() {
|
||||
@@ -27,29 +53,78 @@ export function listExchangeRates() {
|
||||
}
|
||||
|
||||
export function createCategoryRecord(category: any) {
|
||||
const newCategory = {
|
||||
...category,
|
||||
id: MOCK_CATEGORIES.length + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
MOCK_CATEGORIES.push(newCategory);
|
||||
return newCategory;
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
`);
|
||||
const result = stmt.run(
|
||||
category.name,
|
||||
category.type,
|
||||
category.icon || '📝',
|
||||
category.color || '#dfe4ea',
|
||||
category.userId || 1
|
||||
);
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
...category,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建分类失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCategoryRecord(id: number, category: any) {
|
||||
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
|
||||
return MOCK_CATEGORIES[index];
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (category.name) {
|
||||
updates.push('name = ?');
|
||||
params.push(category.name);
|
||||
}
|
||||
if (category.icon) {
|
||||
updates.push('icon = ?');
|
||||
params.push(category.icon);
|
||||
}
|
||||
if (category.color) {
|
||||
updates.push('color = ?');
|
||||
params.push(category.color);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return null;
|
||||
|
||||
params.push(id);
|
||||
const stmt = db.prepare(`
|
||||
UPDATE finance_categories
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(...params);
|
||||
|
||||
// 返回更新后的分类
|
||||
const selectStmt = db.prepare('SELECT * FROM finance_categories WHERE id = ?');
|
||||
return selectStmt.get(id);
|
||||
} catch (error) {
|
||||
console.error('更新分类失败:', error);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteCategoryRecord(id: number) {
|
||||
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES.splice(index, 1);
|
||||
try {
|
||||
// 软删除
|
||||
const stmt = db.prepare(`
|
||||
UPDATE finance_categories
|
||||
SET is_active = 0
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ interface TransactionRow {
|
||||
project: null | string;
|
||||
memo: null | string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
status_updated_at: null | string;
|
||||
reimbursement_batch: null | string;
|
||||
review_notes: null | string;
|
||||
submitted_by: null | string;
|
||||
approved_by: null | string;
|
||||
approved_at: null | string;
|
||||
is_deleted: number;
|
||||
deleted_at: null | string;
|
||||
}
|
||||
@@ -32,8 +39,22 @@ interface TransactionPayload {
|
||||
memo?: null | string;
|
||||
createdAt?: string;
|
||||
isDeleted?: boolean;
|
||||
status?: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: null | string;
|
||||
reviewNotes?: null | string;
|
||||
submittedBy?: null | string;
|
||||
approvedBy?: null | string;
|
||||
approvedAt?: null | string;
|
||||
}
|
||||
|
||||
export type TransactionStatus =
|
||||
| 'draft'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'paid';
|
||||
|
||||
function getExchangeRateToBase(currency: string) {
|
||||
if (currency === BASE_CURRENCY) {
|
||||
return 1;
|
||||
@@ -49,11 +70,11 @@ function mapTransaction(row: TransactionRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: 1,
|
||||
type: row.type as 'expense' | 'income' | 'transfer',
|
||||
amount: row.amount,
|
||||
type: 'expense' as const,
|
||||
amount: Math.abs(row.amount),
|
||||
currency: row.currency,
|
||||
exchangeRateToBase: row.exchange_rate_to_base,
|
||||
amountInBase: row.amount_in_base,
|
||||
amountInBase: Math.abs(row.amount_in_base),
|
||||
categoryId: row.category_id ?? undefined,
|
||||
accountId: row.account_id ?? undefined,
|
||||
transactionDate: row.transaction_date,
|
||||
@@ -61,13 +82,24 @@ function mapTransaction(row: TransactionRow) {
|
||||
project: row.project ?? undefined,
|
||||
memo: row.memo ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
status: row.status as TransactionStatus,
|
||||
statusUpdatedAt: row.status_updated_at ?? undefined,
|
||||
reimbursementBatch: row.reimbursement_batch ?? undefined,
|
||||
reviewNotes: row.review_notes ?? undefined,
|
||||
submittedBy: row.submitted_by ?? undefined,
|
||||
approvedBy: row.approved_by ?? undefined,
|
||||
approvedAt: row.approved_at ?? undefined,
|
||||
isDeleted: Boolean(row.is_deleted),
|
||||
deletedAt: row.deleted_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchTransactions(
|
||||
options: { includeDeleted?: boolean; type?: string } = {},
|
||||
options: {
|
||||
includeDeleted?: boolean;
|
||||
type?: string;
|
||||
statuses?: TransactionStatus[];
|
||||
} = {},
|
||||
) {
|
||||
const clauses: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
@@ -79,11 +111,19 @@ export function fetchTransactions(
|
||||
clauses.push('type = @type');
|
||||
params.type = options.type;
|
||||
}
|
||||
if (options.statuses && options.statuses.length > 0) {
|
||||
clauses.push(
|
||||
`status IN (${options.statuses.map((_, index) => `@status${index}`).join(', ')})`,
|
||||
);
|
||||
options.statuses.forEach((status, index) => {
|
||||
params[`status${index}`] = status;
|
||||
});
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||
);
|
||||
|
||||
return stmt.all(params).map(mapTransaction);
|
||||
@@ -91,7 +131,7 @@ export function fetchTransactions(
|
||||
|
||||
export function getTransactionById(id: number) {
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
|
||||
);
|
||||
const row = stmt.get(id);
|
||||
return row ? mapTransaction(row) : null;
|
||||
@@ -104,9 +144,20 @@ export function createTransaction(payload: TransactionPayload) {
|
||||
payload.createdAt && payload.createdAt.length > 0
|
||||
? payload.createdAt
|
||||
: new Date().toISOString();
|
||||
const status: TransactionStatus = payload.status ?? 'approved';
|
||||
const statusUpdatedAt =
|
||||
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
|
||||
? payload.statusUpdatedAt
|
||||
: createdAt;
|
||||
const approvedAt =
|
||||
payload.approvedAt && payload.approvedAt.length > 0
|
||||
? payload.approvedAt
|
||||
: status === 'approved' || status === 'paid'
|
||||
? statusUpdatedAt
|
||||
: null;
|
||||
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, 0)`,
|
||||
);
|
||||
|
||||
const info = stmt.run({
|
||||
@@ -122,6 +173,13 @@ export function createTransaction(payload: TransactionPayload) {
|
||||
project: payload.project ?? null,
|
||||
memo: payload.memo ?? null,
|
||||
createdAt,
|
||||
status,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch: payload.reimbursementBatch ?? null,
|
||||
reviewNotes: payload.reviewNotes ?? null,
|
||||
submittedBy: payload.submittedBy ?? null,
|
||||
approvedBy: payload.approvedBy ?? null,
|
||||
approvedAt,
|
||||
});
|
||||
|
||||
return getTransactionById(Number(info.lastInsertRowid));
|
||||
@@ -133,6 +191,25 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextStatus = (payload.status ?? current.status ?? 'approved') as TransactionStatus;
|
||||
const statusChanged = nextStatus !== current.status;
|
||||
const statusUpdatedAt =
|
||||
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
|
||||
? payload.statusUpdatedAt
|
||||
: statusChanged
|
||||
? new Date().toISOString()
|
||||
: current.statusUpdatedAt ?? current.createdAt;
|
||||
const approvedAt =
|
||||
payload.approvedAt && payload.approvedAt.length > 0
|
||||
? payload.approvedAt
|
||||
: nextStatus === 'approved' || nextStatus === 'paid'
|
||||
? current.approvedAt ?? (statusChanged ? statusUpdatedAt : null)
|
||||
: null;
|
||||
const approvedBy =
|
||||
nextStatus === 'approved' || nextStatus === 'paid'
|
||||
? payload.approvedBy ?? current.approvedBy ?? null
|
||||
: payload.approvedBy ?? null;
|
||||
|
||||
const next = {
|
||||
type: payload.type ?? current.type,
|
||||
amount: payload.amount ?? current.amount,
|
||||
@@ -144,13 +221,21 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
project: payload.project ?? current.project ?? null,
|
||||
memo: payload.memo ?? current.memo ?? null,
|
||||
isDeleted: payload.isDeleted ?? current.isDeleted,
|
||||
status: nextStatus,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch:
|
||||
payload.reimbursementBatch ?? current.reimbursementBatch ?? null,
|
||||
reviewNotes: payload.reviewNotes ?? current.reviewNotes ?? null,
|
||||
submittedBy: payload.submittedBy ?? current.submittedBy ?? null,
|
||||
approvedBy,
|
||||
approvedAt,
|
||||
};
|
||||
|
||||
const exchangeRate = getExchangeRateToBase(next.currency);
|
||||
const amountInBase = +(next.amount * exchangeRate).toFixed(2);
|
||||
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
|
||||
`UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, status = @status, status_updated_at = @statusUpdatedAt, reimbursement_batch = @reimbursementBatch, review_notes = @reviewNotes, submitted_by = @submittedBy, approved_by = @approvedBy, approved_at = @approvedAt, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
|
||||
);
|
||||
|
||||
const deletedAt = next.isDeleted ? new Date().toISOString() : null;
|
||||
@@ -168,6 +253,13 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
description: next.description,
|
||||
project: next.project,
|
||||
memo: next.memo,
|
||||
status: next.status,
|
||||
statusUpdatedAt: next.statusUpdatedAt,
|
||||
reimbursementBatch: next.reimbursementBatch,
|
||||
reviewNotes: next.reviewNotes,
|
||||
submittedBy: next.submittedBy,
|
||||
approvedBy: next.approvedBy,
|
||||
approvedAt: next.approvedAt,
|
||||
isDeleted: next.isDeleted ? 1 : 0,
|
||||
deletedAt,
|
||||
});
|
||||
@@ -203,12 +295,20 @@ export function replaceAllTransactions(
|
||||
project?: null | string;
|
||||
transactionDate: string;
|
||||
type: string;
|
||||
status?: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: null | string;
|
||||
reviewNotes?: null | string;
|
||||
submittedBy?: null | string;
|
||||
approvedBy?: null | string;
|
||||
approvedAt?: null | string;
|
||||
isDeleted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
db.prepare('DELETE FROM finance_transactions').run();
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, @isDeleted)`,
|
||||
);
|
||||
|
||||
const getRate = db.prepare(
|
||||
@@ -220,15 +320,36 @@ export function replaceAllTransactions(
|
||||
const row = getRate.get(item.currency) as undefined | { rate: number };
|
||||
const rate = row?.rate ?? 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
const createdAt =
|
||||
item.createdAt ??
|
||||
new Date(`${item.transactionDate}T00:00:00Z`).toISOString();
|
||||
const status = item.status ?? 'approved';
|
||||
const statusUpdatedAt =
|
||||
item.statusUpdatedAt ??
|
||||
new Date(
|
||||
`${item.transactionDate}T00:00:00Z`,
|
||||
).toISOString();
|
||||
const approvedAt =
|
||||
item.approvedAt ??
|
||||
(status === 'approved' || status === 'paid' ? statusUpdatedAt : null);
|
||||
insert.run({
|
||||
...item,
|
||||
exchangeRateToBase: rate,
|
||||
amountInBase,
|
||||
project: item.project ?? null,
|
||||
memo: item.memo ?? null,
|
||||
createdAt:
|
||||
item.createdAt ??
|
||||
new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
|
||||
createdAt,
|
||||
status,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch: item.reimbursementBatch ?? null,
|
||||
reviewNotes: item.reviewNotes ?? null,
|
||||
submittedBy: item.submittedBy ?? null,
|
||||
approvedBy:
|
||||
status === 'approved' || status === 'paid'
|
||||
? item.approvedBy ?? null
|
||||
: null,
|
||||
approvedAt,
|
||||
isDeleted: item.isDeleted ? 1 : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
117
apps/backend/utils/media-repository.ts
Normal file
117
apps/backend/utils/media-repository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import db from './sqlite';
|
||||
|
||||
interface MediaRow {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: number;
|
||||
user_id: number;
|
||||
username: null | string;
|
||||
display_name: null | string;
|
||||
file_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: null | string;
|
||||
caption: null | string;
|
||||
file_name: null | string;
|
||||
file_path: string;
|
||||
file_size: null | number;
|
||||
mime_type: null | string;
|
||||
duration: null | number;
|
||||
width: null | number;
|
||||
height: null | number;
|
||||
forwarded_to: null | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MediaMessage {
|
||||
id: number;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
fileType: string;
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
forwardedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
available: boolean;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
function mapMediaRow(row: MediaRow): MediaMessage {
|
||||
const fileExists = existsSync(row.file_path);
|
||||
return {
|
||||
id: row.id,
|
||||
chatId: row.chat_id,
|
||||
messageId: row.message_id,
|
||||
userId: row.user_id,
|
||||
username: row.username ?? undefined,
|
||||
displayName: row.display_name ?? undefined,
|
||||
fileType: row.file_type,
|
||||
fileId: row.file_id,
|
||||
fileUniqueId: row.file_unique_id ?? undefined,
|
||||
caption: row.caption ?? undefined,
|
||||
fileName: row.file_name ?? undefined,
|
||||
filePath: row.file_path,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
mimeType: row.mime_type ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
width: row.width ?? undefined,
|
||||
height: row.height ?? undefined,
|
||||
forwardedTo: row.forwarded_to ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
available: fileExists,
|
||||
downloadUrl: fileExists ? `/finance/media/${row.id}/download` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMediaMessages(params: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
} = {}) {
|
||||
const clauses: string[] = [];
|
||||
const bindParams: Record<string, unknown> = {};
|
||||
|
||||
if (params.fileTypes && params.fileTypes.length > 0) {
|
||||
clauses.push(
|
||||
`file_type IN (${params.fileTypes.map((_, index) => `@type${index}`).join(', ')})`,
|
||||
);
|
||||
params.fileTypes.forEach((type, index) => {
|
||||
bindParams[`type${index}`] = type;
|
||||
});
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const limitClause =
|
||||
params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : '';
|
||||
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages ${where} ORDER BY datetime(created_at) DESC, id DESC ${limitClause}`,
|
||||
);
|
||||
|
||||
return stmt.all(bindParams).map(mapMediaRow);
|
||||
}
|
||||
|
||||
export function getMediaMessageById(id: number) {
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages WHERE id = ?`,
|
||||
);
|
||||
|
||||
const row = stmt.get(id);
|
||||
|
||||
return row ? mapMediaRow(row) : null;
|
||||
}
|
||||
|
||||
15
apps/backend/utils/media-storage.ts
Normal file
15
apps/backend/utils/media-storage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'pathe';
|
||||
|
||||
const MEDIA_ROOT = join(process.cwd(), 'storage', 'telegram-media');
|
||||
|
||||
mkdirSync(MEDIA_ROOT, { recursive: true });
|
||||
|
||||
export function getMediaRoot() {
|
||||
return MEDIA_ROOT;
|
||||
}
|
||||
|
||||
export function resolveMediaAbsolutePath(relativePath: string) {
|
||||
return join(MEDIA_ROOT, relativePath);
|
||||
}
|
||||
|
||||
@@ -693,11 +693,11 @@ export interface Category {
|
||||
}
|
||||
|
||||
export const MOCK_CATEGORIES: Category[] = [
|
||||
// 支出分类
|
||||
// 支出分类 (ID 1-17)
|
||||
{
|
||||
id: 1,
|
||||
userId: null,
|
||||
name: '餐饮',
|
||||
name: '餐饮美食',
|
||||
type: 'expense',
|
||||
icon: '🍜',
|
||||
color: '#ff6b6b',
|
||||
@@ -708,9 +708,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 2,
|
||||
userId: null,
|
||||
name: '交通',
|
||||
name: '佣金/返佣',
|
||||
type: 'expense',
|
||||
icon: '🚗',
|
||||
icon: '💸',
|
||||
color: '#4ecdc4',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
@@ -719,9 +719,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 3,
|
||||
userId: null,
|
||||
name: '购物',
|
||||
name: '分红',
|
||||
type: 'expense',
|
||||
icon: '🛍️',
|
||||
icon: '💰',
|
||||
color: '#95e1d3',
|
||||
sortOrder: 3,
|
||||
isSystem: true,
|
||||
@@ -730,9 +730,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 4,
|
||||
userId: null,
|
||||
name: '娱乐',
|
||||
name: '技术/软件',
|
||||
type: 'expense',
|
||||
icon: '🎮',
|
||||
icon: '💻',
|
||||
color: '#f38181',
|
||||
sortOrder: 4,
|
||||
isSystem: true,
|
||||
@@ -741,9 +741,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 5,
|
||||
userId: null,
|
||||
name: '软件订阅',
|
||||
name: '固定资产',
|
||||
type: 'expense',
|
||||
icon: '💻',
|
||||
icon: '🏠',
|
||||
color: '#aa96da',
|
||||
sortOrder: 5,
|
||||
isSystem: true,
|
||||
@@ -752,9 +752,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 6,
|
||||
userId: null,
|
||||
name: '投资支出',
|
||||
name: '退款',
|
||||
type: 'expense',
|
||||
icon: '📊',
|
||||
icon: '↩️',
|
||||
color: '#fcbad3',
|
||||
sortOrder: 6,
|
||||
isSystem: true,
|
||||
@@ -763,9 +763,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 7,
|
||||
userId: null,
|
||||
name: '医疗健康',
|
||||
name: '服务器/技术',
|
||||
type: 'expense',
|
||||
icon: '🏥',
|
||||
icon: '🖥️',
|
||||
color: '#a8d8ea',
|
||||
sortOrder: 7,
|
||||
isSystem: true,
|
||||
@@ -774,9 +774,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 8,
|
||||
userId: null,
|
||||
name: '房租房贷',
|
||||
name: '工资',
|
||||
type: 'expense',
|
||||
icon: '🏠',
|
||||
icon: '💼',
|
||||
color: '#ffcccc',
|
||||
sortOrder: 8,
|
||||
isSystem: true,
|
||||
@@ -785,9 +785,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 9,
|
||||
userId: null,
|
||||
name: '教育',
|
||||
name: '借款/转账',
|
||||
type: 'expense',
|
||||
icon: '📚',
|
||||
icon: '🔄',
|
||||
color: '#ffd3b6',
|
||||
sortOrder: 9,
|
||||
isSystem: true,
|
||||
@@ -796,29 +796,106 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 10,
|
||||
userId: null,
|
||||
name: '广告推广',
|
||||
type: 'expense',
|
||||
icon: '📢',
|
||||
color: '#dfe4ea',
|
||||
sortOrder: 10,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
userId: null,
|
||||
name: '交通出行',
|
||||
type: 'expense',
|
||||
icon: '🚗',
|
||||
color: '#74b9ff',
|
||||
sortOrder: 11,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
userId: null,
|
||||
name: '购物消费',
|
||||
type: 'expense',
|
||||
icon: '🛍️',
|
||||
color: '#fd79a8',
|
||||
sortOrder: 12,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
userId: null,
|
||||
name: '娱乐休闲',
|
||||
type: 'expense',
|
||||
icon: '🎮',
|
||||
color: '#fdcb6e',
|
||||
sortOrder: 13,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
userId: null,
|
||||
name: '医疗健康',
|
||||
type: 'expense',
|
||||
icon: '🏥',
|
||||
color: '#55efc4',
|
||||
sortOrder: 14,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
userId: null,
|
||||
name: '教育学习',
|
||||
type: 'expense',
|
||||
icon: '📚',
|
||||
color: '#a29bfe',
|
||||
sortOrder: 15,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
userId: null,
|
||||
name: '房租房贷',
|
||||
type: 'expense',
|
||||
icon: '🏘️',
|
||||
color: '#ff7675',
|
||||
sortOrder: 16,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
userId: null,
|
||||
name: '其他支出',
|
||||
type: 'expense',
|
||||
icon: '📝',
|
||||
color: '#dfe4ea',
|
||||
color: '#b2bec3',
|
||||
sortOrder: 99,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
|
||||
// 收入分类
|
||||
// 收入分类 (ID 18-23)
|
||||
{
|
||||
id: 11,
|
||||
id: 18,
|
||||
userId: null,
|
||||
name: '工资',
|
||||
name: '工资收入',
|
||||
type: 'income',
|
||||
icon: '💼',
|
||||
icon: '💵',
|
||||
color: '#38ada9',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
id: 19,
|
||||
userId: null,
|
||||
name: '奖金',
|
||||
type: 'income',
|
||||
@@ -829,7 +906,7 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
id: 20,
|
||||
userId: null,
|
||||
name: '投资收益',
|
||||
type: 'income',
|
||||
@@ -840,7 +917,7 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
id: 21,
|
||||
userId: null,
|
||||
name: '副业收入',
|
||||
type: 'income',
|
||||
@@ -851,12 +928,23 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
id: 22,
|
||||
userId: null,
|
||||
name: '退款收入',
|
||||
type: 'income',
|
||||
icon: '↩️',
|
||||
color: '#82ccdd',
|
||||
sortOrder: 5,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
userId: null,
|
||||
name: '其他收入',
|
||||
type: 'income',
|
||||
icon: '💰',
|
||||
color: '#82ccdd',
|
||||
color: '#10ac84',
|
||||
sortOrder: 99,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
|
||||
@@ -9,6 +9,24 @@ mkdirSync(dirname(dbFile), { recursive: true });
|
||||
|
||||
const database = new Database(dbFile);
|
||||
|
||||
function assertIdentifier(name: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||||
throw new Error(`Invalid identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function ensureColumn(table: string, column: string, definition: string) {
|
||||
const safeTable = assertIdentifier(table);
|
||||
const safeColumn = assertIdentifier(column);
|
||||
const columns = database
|
||||
.prepare<{ name: string }>(`PRAGMA table_info(${safeTable})`)
|
||||
.all();
|
||||
if (!columns.some((item) => item.name === safeColumn)) {
|
||||
database.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
database.pragma('journal_mode = WAL');
|
||||
|
||||
database.exec(`
|
||||
@@ -72,6 +90,13 @@ database.exec(`
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'approved',
|
||||
status_updated_at TEXT,
|
||||
reimbursement_batch TEXT,
|
||||
review_notes TEXT,
|
||||
submitted_by TEXT,
|
||||
approved_by TEXT,
|
||||
approved_at TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY (currency) REFERENCES finance_currencies(code),
|
||||
@@ -80,4 +105,56 @@ database.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status',
|
||||
"status TEXT NOT NULL DEFAULT 'approved'",
|
||||
);
|
||||
ensureColumn('finance_transactions', 'status_updated_at', 'status_updated_at TEXT');
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'reimbursement_batch',
|
||||
'reimbursement_batch TEXT',
|
||||
);
|
||||
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
|
||||
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_media_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
display_name TEXT,
|
||||
file_type TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
file_unique_id TEXT,
|
||||
caption TEXT,
|
||||
file_name TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
duration INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
forwarded_to INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(chat_id, message_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_created_at
|
||||
ON finance_media_messages (created_at DESC);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_user_id
|
||||
ON finance_media_messages (user_id);
|
||||
`);
|
||||
|
||||
export default database;
|
||||
|
||||
73
apps/backend/utils/telegram-webhook.ts
Normal file
73
apps/backend/utils/telegram-webhook.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const DEFAULT_WEBHOOK_URL =
|
||||
process.env.TELEGRAM_WEBHOOK_URL ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_URL ??
|
||||
'http://192.168.9.28:8889/webhook/transaction';
|
||||
const DEFAULT_WEBHOOK_SECRET =
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_SECRET ??
|
||||
'ktapp.cc';
|
||||
|
||||
interface TransactionPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function postToWebhook(
|
||||
payload: TransactionPayload,
|
||||
webhookURL: string,
|
||||
webhookSecret: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(webhookURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${webhookSecret}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.error(
|
||||
'[telegram-webhook] Failed to notify webhook',
|
||||
response.status,
|
||||
text,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[telegram-webhook] Webhook request error', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTransactionWebhook(
|
||||
transaction: TransactionPayload,
|
||||
options: {
|
||||
webhookURL?: string;
|
||||
webhookSecret?: string;
|
||||
action?: string;
|
||||
source?: string;
|
||||
} = {},
|
||||
) {
|
||||
const url = (options.webhookURL ?? DEFAULT_WEBHOOK_URL).trim();
|
||||
const secret = (options.webhookSecret ?? DEFAULT_WEBHOOK_SECRET).trim();
|
||||
|
||||
if (!url || !secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TransactionPayload = {
|
||||
...transaction,
|
||||
};
|
||||
|
||||
if (options.action) {
|
||||
payload.action = options.action;
|
||||
}
|
||||
|
||||
if (options.source) {
|
||||
payload.source = options.source;
|
||||
} else {
|
||||
payload.source = payload.source ?? 'finwise-backend';
|
||||
}
|
||||
|
||||
await postToWebhook(payload, url, secret);
|
||||
}
|
||||
56
apps/finance-mcp-service/README.md
Normal file
56
apps/finance-mcp-service/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Finwise Finance MCP Service
|
||||
|
||||
该包将 Finwise Pro 的 `/api/finance/*` 接口封装为 Model Context Protocol (MCP) 工具,方便 Codex、Claude 等 MCP 客户端直接调用财务能力。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. 构建服务
|
||||
|
||||
```bash
|
||||
(本服务为纯 Node.js 实现,如无额外需求可跳过构建)
|
||||
```
|
||||
|
||||
3. 启动服务(示例)
|
||||
|
||||
```bash
|
||||
FINANCE_BASIC_USERNAME=atai \
|
||||
FINANCE_BASIC_PASSWORD=wengewudi666808 \
|
||||
node apps/finance-mcp-service/src/index.js
|
||||
```
|
||||
|
||||
可选环境变量:
|
||||
|
||||
| 变量 | 含义 |
|
||||
| --- | --- |
|
||||
| `FINANCE_API_BASE_URL` | 默认 `http://172.16.74.149:5666`,如需变更可重设。 |
|
||||
| `FINANCE_API_KEY` | 将作为 Bearer Token 附加在请求头。 |
|
||||
| `FINANCE_API_TIMEOUT` | 请求超时(毫秒)。 |
|
||||
| `FINANCE_BASIC_USERNAME` / `FINANCE_BASIC_PASSWORD` | 使用 HTTP Basic Auth 访问后端。 |
|
||||
|
||||
如需在 Codex 中自动启动该 MCP 服务,可在 `config.json` 中加入以下配置片段(路径默认位于 `~/.config/codex/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"finwise-finance": {
|
||||
"command": "node",
|
||||
"args": ["apps/finance-mcp-service/src/index.js"],
|
||||
"env": {
|
||||
"FINANCE_BASIC_USERNAME": "atai",
|
||||
"FINANCE_BASIC_PASSWORD": "wengewudi666808"
|
||||
},
|
||||
"cwd": "/Users/fuwuqi/Projects/web-apps/finwise-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置完成后,重启 Codex 即可在 MCP 面板中看到 `finwise-finance`,并通过工具调用各类财务接口。
|
||||
|
||||
工具清单与入参定义详见 `src/index.ts`。
|
||||
12
apps/finance-mcp-service/package.json
Normal file
12
apps/finance-mcp-service/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@vben/finance-mcp-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP service exposing Finwise Pro finance APIs",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
9
apps/finance-mcp-service/pnpm-lock.yaml
generated
Normal file
9
apps/finance-mcp-service/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
285
apps/finance-mcp-service/src/finance-client.js
Normal file
285
apps/finance-mcp-service/src/finance-client.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export class FinanceClient {
|
||||
constructor(config) {
|
||||
if (!config?.baseUrl) {
|
||||
throw new Error('FinanceClient requires a baseUrl');
|
||||
}
|
||||
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = config.apiKey;
|
||||
this.basicAuth = validateBasicAuth(config.basicAuth);
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
}
|
||||
|
||||
async listAccounts(params = {}) {
|
||||
return this.get('/api/finance/accounts', params);
|
||||
}
|
||||
|
||||
async listBudgets() {
|
||||
return this.get('/api/finance/budgets');
|
||||
}
|
||||
|
||||
async createBudget(payload) {
|
||||
return this.post('/api/finance/budgets', payload);
|
||||
}
|
||||
|
||||
async updateBudget(id, payload) {
|
||||
return this.put(`/api/finance/budgets/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteBudget(id) {
|
||||
return this.delete(`/api/finance/budgets/${id}`);
|
||||
}
|
||||
|
||||
async listCategories(params = {}) {
|
||||
return this.get('/api/finance/categories', params);
|
||||
}
|
||||
|
||||
async createCategory(payload) {
|
||||
return this.post('/api/finance/categories', payload);
|
||||
}
|
||||
|
||||
async updateCategory(id, payload) {
|
||||
return this.put(`/api/finance/categories/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteCategory(id) {
|
||||
return this.delete(`/api/finance/categories/${id}`);
|
||||
}
|
||||
|
||||
async listCurrencies() {
|
||||
return this.get('/api/finance/currencies');
|
||||
}
|
||||
|
||||
async listExchangeRates(params = {}) {
|
||||
const query = {};
|
||||
if (params.fromCurrency) query.from = params.fromCurrency;
|
||||
if (params.toCurrency) query.to = params.toCurrency;
|
||||
if (params.date) query.date = params.date;
|
||||
return this.get('/api/finance/exchange-rates', query);
|
||||
}
|
||||
|
||||
async listTransactions(params = {}) {
|
||||
const query = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return this.get('/api/finance/transactions', query);
|
||||
}
|
||||
|
||||
async createTransaction(payload) {
|
||||
return this.post('/api/finance/transactions', payload);
|
||||
}
|
||||
|
||||
async updateTransaction(id, payload) {
|
||||
return this.put(`/api/finance/transactions/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteTransaction(id) {
|
||||
return this.delete(`/api/finance/transactions/${id}`);
|
||||
}
|
||||
|
||||
async listReimbursements(params = {}) {
|
||||
const query = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return this.get('/api/finance/reimbursements', query);
|
||||
}
|
||||
|
||||
async createReimbursement(payload) {
|
||||
return this.post('/api/finance/reimbursements', payload);
|
||||
}
|
||||
|
||||
async updateReimbursement(id, payload) {
|
||||
return this.put(`/api/finance/reimbursements/${id}`, payload);
|
||||
}
|
||||
|
||||
async listMedia(params = {}) {
|
||||
const query = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
|
||||
return this.get('/api/finance/media', query);
|
||||
}
|
||||
|
||||
async getMediaById(id) {
|
||||
return this.get(`/api/finance/media/${id}`);
|
||||
}
|
||||
|
||||
async downloadMedia(id) {
|
||||
const url = this.createUrl(`/api/finance/media/${id}/download`);
|
||||
const response = await this.performFetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(false),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await this.safeParseEnvelope(response);
|
||||
if (payload) {
|
||||
throw new Error(payload.message || 'Failed to download media file');
|
||||
}
|
||||
throw new Error(`Failed to download media file (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
fileName: this.extractFileName(response.headers.get('content-disposition')),
|
||||
mimeType: response.headers.get('content-type') ?? 'application/octet-stream',
|
||||
size: buffer.byteLength,
|
||||
base64: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
async get(path, query) {
|
||||
return this.request('GET', path, { query });
|
||||
}
|
||||
|
||||
async post(path, body) {
|
||||
return this.request('POST', path, { body });
|
||||
}
|
||||
|
||||
async put(path, body) {
|
||||
return this.request('PUT', path, { body });
|
||||
}
|
||||
|
||||
async delete(path) {
|
||||
return this.request('DELETE', path);
|
||||
}
|
||||
|
||||
async request(method, path, options = {}) {
|
||||
const url = this.createUrl(path);
|
||||
|
||||
if (options.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) url.searchParams.set(key, value.join(','));
|
||||
} else if (typeof value === 'boolean') {
|
||||
url.searchParams.set(key, value ? 'true' : 'false');
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.performFetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await this.parseEnvelope(response, path);
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(payload.message || 'Finance API returned an error');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
createUrl(path) {
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
return new URL(path, this.baseUrl);
|
||||
}
|
||||
|
||||
buildHeaders(json) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (json) headers['Content-Type'] = 'application/json';
|
||||
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
else if (this.basicAuth) headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async performFetch(url, init) {
|
||||
const controller = this.timeoutMs ? new AbortController() : undefined;
|
||||
let timer;
|
||||
|
||||
if (controller) {
|
||||
init.signal = controller.signal;
|
||||
timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Request to ${url.pathname} timed out`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async parseEnvelope(response, path) {
|
||||
const payload = await this.safeParseEnvelope(response);
|
||||
|
||||
if (!payload) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Unexpected response from ${path}: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || `Finance API request failed (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async safeParseEnvelope(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) return null;
|
||||
|
||||
try {
|
||||
return await response.clone().json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extractFileName(contentDisposition) {
|
||||
if (!contentDisposition) return undefined;
|
||||
|
||||
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
|
||||
if (filenameStar?.[1]) {
|
||||
const value = filenameStar[1].replace(/^UTF-8''/, '');
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
return filename?.[1];
|
||||
}
|
||||
}
|
||||
|
||||
const validateBasicAuth = (credentials) => {
|
||||
if (!credentials) return undefined;
|
||||
const username = credentials.username ?? credentials.user ?? credentials.login;
|
||||
const password = credentials.password ?? credentials.pass;
|
||||
|
||||
if (!username && !password) return undefined;
|
||||
if (!username || !password) {
|
||||
throw new Error('FinanceClient basicAuth requires both username and password');
|
||||
}
|
||||
|
||||
return { username: String(username), password: String(password) };
|
||||
};
|
||||
|
||||
const createBasicToken = ({ username, password }) =>
|
||||
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
|
||||
901
apps/finance-mcp-service/src/index.js
Normal file
901
apps/finance-mcp-service/src/index.js
Normal file
@@ -0,0 +1,901 @@
|
||||
import process from 'node:process';
|
||||
|
||||
import { FinanceClient } from './finance-client.js';
|
||||
|
||||
process.on('exit', (code) => {
|
||||
process.stderr.write(`[finwise-finance] process exit with code ${code}\n`);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
process.stderr.write(`[finwise-finance] uncaughtException: ${error.stack ?? error.message}\n`);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
process.stderr.write(`[finwise-finance] unhandledRejection: ${reason}\n`);
|
||||
});
|
||||
|
||||
class McpServer {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.tools = new Map();
|
||||
this.metadata = [];
|
||||
this.buffer = '';
|
||||
this.expectedLength = null;
|
||||
this.initialized = false;
|
||||
|
||||
for (const tool of options.tools) {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(`Duplicate MCP tool name: ${tool.name}`);
|
||||
}
|
||||
this.tools.set(tool.name, tool);
|
||||
this.metadata.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
this.buffer += chunk;
|
||||
void this.drain();
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
this.log('stdin ended');
|
||||
});
|
||||
process.stdin.on('close', () => {
|
||||
this.log('stdin closed');
|
||||
});
|
||||
process.stdin.resume();
|
||||
this.log('MCP service ready');
|
||||
}
|
||||
|
||||
write(payload) {
|
||||
const json = JSON.stringify(payload);
|
||||
const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
|
||||
process.stdout.write(frame);
|
||||
}
|
||||
|
||||
respond(id, result) {
|
||||
this.log(`responding to ${id} with result`);
|
||||
if (id === undefined) return;
|
||||
this.write({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message) {
|
||||
this.log(`responding error to ${id}: [${code}] ${message}`);
|
||||
if (id === undefined) return;
|
||||
this.write({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
this.log(`notifying ${method}`);
|
||||
this.write({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
async drain() {
|
||||
while (true) {
|
||||
if (this.expectedLength === null) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) return;
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const match = header.match(/content-length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
this.expectedLength = Number.parseInt(match[1], 10);
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
}
|
||||
|
||||
if (this.buffer.length < (this.expectedLength ?? 0)) return;
|
||||
|
||||
const body = this.buffer.slice(0, this.expectedLength ?? 0);
|
||||
this.buffer = this.buffer.slice(this.expectedLength ?? 0);
|
||||
this.expectedLength = null;
|
||||
|
||||
await this.handleMessage(body);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMessage(payload) {
|
||||
this.log(`received payload: ${payload}`);
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(payload);
|
||||
} catch {
|
||||
this.respondError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
|
||||
this.respondError(request?.id, -32600, 'Invalid Request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dispatch(request);
|
||||
} catch (error) {
|
||||
this.log(`Unexpected error: ${error.message}`);
|
||||
this.respondError(request.id, -32000, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatch(request) {
|
||||
switch (request.method) {
|
||||
case 'initialize': {
|
||||
if (this.initialized) {
|
||||
this.respondError(request.id, -32600, 'Already initialized');
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
this.respond(request.id, {
|
||||
protocolVersion: '2024-10-07',
|
||||
capabilities: { tools: { list: true, call: true } },
|
||||
service: {
|
||||
name: this.options.name,
|
||||
version: this.options.version,
|
||||
description: this.options.description,
|
||||
},
|
||||
});
|
||||
this.notify('notifications/ready', {});
|
||||
return;
|
||||
}
|
||||
|
||||
case 'tools/list': {
|
||||
this.assertInitialized('tools/list');
|
||||
this.respond(request.id, { tools: this.metadata });
|
||||
return;
|
||||
}
|
||||
|
||||
case 'tools/call': {
|
||||
this.assertInitialized('tools/call');
|
||||
const params = request.params ?? {};
|
||||
const toolName = params.name;
|
||||
if (!toolName || typeof toolName !== 'string') {
|
||||
this.respondError(request.id, -32602, 'Tool name is required');
|
||||
return;
|
||||
}
|
||||
const tool = this.tools.get(toolName);
|
||||
if (!tool) {
|
||||
this.respondError(request.id, -32601, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await tool.handler(params.arguments ?? {});
|
||||
this.respond(request.id, result);
|
||||
} catch (error) {
|
||||
this.respondError(request.id, -32001, error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
this.respond(request.id, 'pong');
|
||||
return;
|
||||
}
|
||||
|
||||
case 'shutdown': {
|
||||
this.respond(request.id, null);
|
||||
process.nextTick(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.respondError(request.id, -32601, `Method not found: ${request.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertInitialized(method) {
|
||||
if (!this.initialized) {
|
||||
throw new Error(`Received ${method} before initialize`);
|
||||
}
|
||||
}
|
||||
|
||||
log(message) {
|
||||
process.stderr.write(`[${this.options.name}] ${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonResult = (data) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'application/json',
|
||||
data,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ensureNumber = (value, field) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
throw new Error(`${field} must be a number`);
|
||||
};
|
||||
|
||||
const optionalNumber = (value, field) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return ensureNumber(value, field);
|
||||
};
|
||||
|
||||
const optionalNullableNumber = (value, field) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'null') return null;
|
||||
}
|
||||
return ensureNumber(value, field);
|
||||
};
|
||||
|
||||
const ensureString = (value, field) => {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error(`${field} cannot be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
if (value === undefined || value === null) throw new Error(`${field} is required`);
|
||||
return ensureString(String(value), field);
|
||||
};
|
||||
|
||||
const optionalString = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const optionalNullableString = (value) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (normalized.toLowerCase() === 'null') return null;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const optionalBoolean = (value, field) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'n'].includes(normalized)) return false;
|
||||
}
|
||||
throw new Error(`${field} must be boolean`);
|
||||
};
|
||||
|
||||
const parseStringArray = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
let items = [];
|
||||
if (Array.isArray(value)) {
|
||||
items = value.map((item) => String(item).trim()).filter(Boolean);
|
||||
} else if (typeof value === 'string') {
|
||||
items = value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
} else {
|
||||
items = [String(value).trim()].filter(Boolean);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
};
|
||||
|
||||
const buildTransactionCreatePayload = (args, options = {}) => {
|
||||
const payload = {
|
||||
type: optionalString(args?.type) ?? options.defaultType ?? ensureString(args?.type, 'type'),
|
||||
amount: ensureNumber(args?.amount, 'amount'),
|
||||
currency: optionalString(args?.currency) ?? 'CNY',
|
||||
transactionDate: ensureString(args?.transactionDate, 'transactionDate'),
|
||||
};
|
||||
|
||||
const categoryId = optionalNullableNumber(args?.categoryId, 'categoryId');
|
||||
if (categoryId !== undefined) payload.categoryId = categoryId;
|
||||
|
||||
const accountId = optionalNullableNumber(args?.accountId, 'accountId');
|
||||
if (accountId !== undefined) payload.accountId = accountId;
|
||||
|
||||
const description = optionalString(args?.description);
|
||||
if (description !== undefined) payload.description = description;
|
||||
|
||||
const project = optionalNullableString(args?.project);
|
||||
if (project !== undefined) payload.project = project;
|
||||
|
||||
const memo = optionalNullableString(args?.memo);
|
||||
if (memo !== undefined) payload.memo = memo;
|
||||
|
||||
const status = optionalString(args?.status);
|
||||
if (status !== undefined) payload.status = status;
|
||||
|
||||
const reimbursementBatch = optionalNullableString(args?.reimbursementBatch);
|
||||
if (reimbursementBatch !== undefined) payload.reimbursementBatch = reimbursementBatch;
|
||||
|
||||
const reviewNotes = optionalNullableString(args?.reviewNotes);
|
||||
if (reviewNotes !== undefined) payload.reviewNotes = reviewNotes;
|
||||
|
||||
const submittedBy = optionalNullableString(args?.submittedBy);
|
||||
if (submittedBy !== undefined) payload.submittedBy = submittedBy;
|
||||
|
||||
const approvedBy = optionalNullableString(args?.approvedBy);
|
||||
if (approvedBy !== undefined) payload.approvedBy = approvedBy;
|
||||
|
||||
const approvedAt = optionalNullableString(args?.approvedAt);
|
||||
if (approvedAt !== undefined) payload.approvedAt = approvedAt;
|
||||
|
||||
const statusUpdatedAt = optionalNullableString(args?.statusUpdatedAt);
|
||||
if (statusUpdatedAt !== undefined) payload.statusUpdatedAt = statusUpdatedAt;
|
||||
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const buildTransactionUpdatePayload = (args) => {
|
||||
const payload = {};
|
||||
|
||||
if (args?.type !== undefined) payload.type = ensureString(args.type, 'type');
|
||||
if (args?.amount !== undefined) payload.amount = ensureNumber(args.amount, 'amount');
|
||||
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
||||
if (args?.transactionDate !== undefined) payload.transactionDate = ensureString(args.transactionDate, 'transactionDate');
|
||||
if (args?.categoryId !== undefined) payload.categoryId = optionalNullableNumber(args.categoryId, 'categoryId');
|
||||
if (args?.accountId !== undefined) payload.accountId = optionalNullableNumber(args.accountId, 'accountId');
|
||||
if (args?.description !== undefined) payload.description = args.description === null ? '' : String(args.description);
|
||||
if (args?.project !== undefined) payload.project = optionalNullableString(args.project) ?? null;
|
||||
if (args?.memo !== undefined) payload.memo = optionalNullableString(args.memo) ?? null;
|
||||
if (args?.status !== undefined) payload.status = ensureString(args.status, 'status');
|
||||
if (args?.statusUpdatedAt !== undefined) payload.statusUpdatedAt = ensureString(args.statusUpdatedAt, 'statusUpdatedAt');
|
||||
if (args?.reimbursementBatch !== undefined) payload.reimbursementBatch = optionalNullableString(args.reimbursementBatch) ?? null;
|
||||
if (args?.reviewNotes !== undefined) payload.reviewNotes = optionalNullableString(args.reviewNotes) ?? null;
|
||||
if (args?.submittedBy !== undefined) payload.submittedBy = optionalNullableString(args.submittedBy) ?? null;
|
||||
if (args?.approvedBy !== undefined) payload.approvedBy = optionalNullableString(args.approvedBy) ?? null;
|
||||
if (args?.approvedAt !== undefined) payload.approvedAt = optionalNullableString(args.approvedAt) ?? null;
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const createFinanceTools = (client) => {
|
||||
const tools = [];
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_accounts',
|
||||
description: '列出账户,可选货币过滤',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
currency: { type: 'string', description: 'ISO 4217 货币代码' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const currency = optionalString(args?.currency);
|
||||
return jsonResult(await client.listAccounts(currency ? { currency } : {}));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_budgets',
|
||||
description: '查询预算列表',
|
||||
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
||||
handler: async () => jsonResult(await client.listBudgets()),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_budget',
|
||||
description: '创建预算',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['category', 'categoryId', 'limit', 'currency', 'period'],
|
||||
properties: {
|
||||
category: { type: 'string' },
|
||||
categoryId: { type: 'number' },
|
||||
emoji: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
spent: { type: 'number' },
|
||||
remaining: { type: 'number' },
|
||||
percentage: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
period: { type: 'string' },
|
||||
alertThreshold: { type: 'number' },
|
||||
description: { type: 'string' },
|
||||
autoRenew: { type: 'boolean' },
|
||||
overspendAlert: { type: 'boolean' },
|
||||
dailyReminder: { type: 'boolean' },
|
||||
monthlyTrend: { type: 'number' },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const payload = {
|
||||
category: ensureString(args?.category, 'category'),
|
||||
categoryId: ensureNumber(args?.categoryId, 'categoryId'),
|
||||
emoji: optionalString(args?.emoji),
|
||||
limit: ensureNumber(args?.limit, 'limit'),
|
||||
spent: optionalNumber(args?.spent, 'spent'),
|
||||
remaining: optionalNumber(args?.remaining, 'remaining'),
|
||||
percentage: optionalNumber(args?.percentage, 'percentage'),
|
||||
currency: ensureString(args?.currency, 'currency'),
|
||||
period: ensureString(args?.period, 'period'),
|
||||
alertThreshold: optionalNumber(args?.alertThreshold, 'alertThreshold'),
|
||||
description: optionalString(args?.description),
|
||||
autoRenew: optionalBoolean(args?.autoRenew, 'autoRenew'),
|
||||
overspendAlert: optionalBoolean(args?.overspendAlert, 'overspendAlert'),
|
||||
dailyReminder: optionalBoolean(args?.dailyReminder, 'dailyReminder'),
|
||||
monthlyTrend: optionalNumber(args?.monthlyTrend, 'monthlyTrend'),
|
||||
isDeleted: optionalBoolean(args?.isDeleted, 'isDeleted'),
|
||||
};
|
||||
return jsonResult(await client.createBudget(payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_budget',
|
||||
description: '更新预算',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
category: { type: 'string' },
|
||||
categoryId: { type: 'number' },
|
||||
emoji: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
spent: { type: 'number' },
|
||||
remaining: { type: 'number' },
|
||||
percentage: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
period: { type: 'string' },
|
||||
alertThreshold: { type: 'number' },
|
||||
description: { type: 'string' },
|
||||
autoRenew: { type: 'boolean' },
|
||||
overspendAlert: { type: 'boolean' },
|
||||
dailyReminder: { type: 'boolean' },
|
||||
monthlyTrend: { type: 'number' },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const payload = {};
|
||||
if (args?.category !== undefined) payload.category = ensureString(args.category, 'category');
|
||||
if (args?.categoryId !== undefined) payload.categoryId = ensureNumber(args.categoryId, 'categoryId');
|
||||
if (args?.emoji !== undefined) payload.emoji = optionalString(args.emoji);
|
||||
if (args?.limit !== undefined) payload.limit = ensureNumber(args.limit, 'limit');
|
||||
if (args?.spent !== undefined) payload.spent = ensureNumber(args.spent, 'spent');
|
||||
if (args?.remaining !== undefined) payload.remaining = ensureNumber(args.remaining, 'remaining');
|
||||
if (args?.percentage !== undefined) payload.percentage = ensureNumber(args.percentage, 'percentage');
|
||||
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
||||
if (args?.period !== undefined) payload.period = ensureString(args.period, 'period');
|
||||
if (args?.alertThreshold !== undefined) payload.alertThreshold = ensureNumber(args.alertThreshold, 'alertThreshold');
|
||||
if (args?.description !== undefined) payload.description = optionalString(args.description);
|
||||
const autoRenew = optionalBoolean(args?.autoRenew, 'autoRenew');
|
||||
if (autoRenew !== undefined) payload.autoRenew = autoRenew;
|
||||
const overspendAlert = optionalBoolean(args?.overspendAlert, 'overspendAlert');
|
||||
if (overspendAlert !== undefined) payload.overspendAlert = overspendAlert;
|
||||
const dailyReminder = optionalBoolean(args?.dailyReminder, 'dailyReminder');
|
||||
if (dailyReminder !== undefined) payload.dailyReminder = dailyReminder;
|
||||
if (args?.monthlyTrend !== undefined) payload.monthlyTrend = ensureNumber(args.monthlyTrend, 'monthlyTrend');
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
return jsonResult(await client.updateBudget(id, payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_budget',
|
||||
description: '删除预算(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteBudget(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_categories',
|
||||
description: '查询分类,可按类型过滤',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string', description: 'expense / income' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
return jsonResult(await client.listCategories(type ? { type } : {}));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_category',
|
||||
description: '创建分类',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'type'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.createCategory({
|
||||
name: ensureString(args?.name, 'name'),
|
||||
type: ensureString(args?.type, 'type'),
|
||||
icon: optionalString(args?.icon),
|
||||
color: optionalString(args?.color),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_category',
|
||||
description: '更新分类',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
isActive: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const payload = {};
|
||||
if (args?.name !== undefined) payload.name = ensureString(args.name, 'name');
|
||||
if (args?.icon !== undefined) payload.icon = optionalString(args.icon);
|
||||
if (args?.color !== undefined) payload.color = optionalString(args.color);
|
||||
const isActive = optionalBoolean(args?.isActive, 'isActive');
|
||||
if (isActive !== undefined) payload.isActive = isActive;
|
||||
return jsonResult(await client.updateCategory(id, payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_category',
|
||||
description: '删除分类(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteCategory(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_currencies',
|
||||
description: '列出可用货币',
|
||||
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
||||
handler: async () => jsonResult(await client.listCurrencies()),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_exchange_rates',
|
||||
description: '查询汇率',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fromCurrency: { type: 'string' },
|
||||
toCurrency: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const params = {};
|
||||
if (args?.fromCurrency !== undefined) params.fromCurrency = ensureString(args.fromCurrency, 'fromCurrency');
|
||||
if (args?.toCurrency !== undefined) params.toCurrency = ensureString(args.toCurrency, 'toCurrency');
|
||||
if (args?.date !== undefined) params.date = ensureString(args.date, 'date');
|
||||
return jsonResult(await client.listExchangeRates(params));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_transactions',
|
||||
description: '查询交易列表',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
includeDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
const statuses = parseStringArray(args?.statuses);
|
||||
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
||||
return jsonResult(
|
||||
await client.listTransactions({
|
||||
...(type ? { type } : {}),
|
||||
...(statuses ? { statuses } : {}),
|
||||
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_transaction',
|
||||
description: '创建交易',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['type', 'amount', 'transactionDate'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
statusUpdatedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.createTransaction(buildTransactionCreatePayload(args))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_transaction',
|
||||
description: '更新交易',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
statusUpdatedAt: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.updateTransaction(ensureNumber(args?.id, 'id'), buildTransactionUpdatePayload(args)),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_transaction',
|
||||
description: '删除交易(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteTransaction(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_reimbursements',
|
||||
description: '查询报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
includeDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
const statuses = parseStringArray(args?.statuses);
|
||||
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
||||
return jsonResult(
|
||||
await client.listReimbursements({
|
||||
...(type ? { type } : {}),
|
||||
...(statuses ? { statuses } : {}),
|
||||
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_reimbursement',
|
||||
description: '创建报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['amount', 'transactionDate'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
statusUpdatedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.createReimbursement(buildTransactionCreatePayload(args, { defaultType: 'expense' })),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_reimbursement',
|
||||
description: '更新报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
statusUpdatedAt: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.updateReimbursement(
|
||||
ensureNumber(args?.id, 'id'),
|
||||
buildTransactionUpdatePayload(args),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_media',
|
||||
description: '查询媒体消息',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
limit: { type: 'number' },
|
||||
fileTypes: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const limit = optionalNumber(args?.limit, 'limit');
|
||||
const fileTypes = parseStringArray(args?.fileTypes);
|
||||
return jsonResult(
|
||||
await client.listMedia({
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
...(fileTypes ? { fileTypes } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_get_media',
|
||||
description: '根据 ID 获取媒体详情',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.getMediaById(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_download_media',
|
||||
description: '下载媒体文件并返回 Base64',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
includeMetadata: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileName: { type: ['string', 'null'] },
|
||||
mimeType: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
base64: { type: 'string' },
|
||||
metadata: { type: ['object', 'null'] },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const includeMetadata = optionalBoolean(args?.includeMetadata, 'includeMetadata');
|
||||
const file = await client.downloadMedia(id);
|
||||
const metadata = includeMetadata === false ? null : await client.getMediaById(id);
|
||||
return jsonResult({ ...file, metadata });
|
||||
},
|
||||
});
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
const createServer = () => {
|
||||
const baseUrl =
|
||||
process.env.FINANCE_API_BASE_URL ?? 'http://172.16.74.149:5666';
|
||||
const apiKey = process.env.FINANCE_API_KEY;
|
||||
const timeoutEnv = process.env.FINANCE_API_TIMEOUT;
|
||||
const timeout = timeoutEnv ? Number.parseInt(timeoutEnv, 10) : undefined;
|
||||
const basicUsername =
|
||||
process.env.FINANCE_BASIC_USERNAME ??
|
||||
process.env.FINANCE_BASIC_USER ??
|
||||
process.env.FINANCE_USERNAME;
|
||||
const basicPassword =
|
||||
process.env.FINANCE_BASIC_PASSWORD ??
|
||||
process.env.FINANCE_PASSWORD;
|
||||
const basicAuth =
|
||||
basicUsername && basicPassword ? { username: basicUsername, password: basicPassword } : undefined;
|
||||
|
||||
const client = new FinanceClient({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
basicAuth,
|
||||
timeoutMs: Number.isFinite(timeout ?? NaN) ? timeout : undefined,
|
||||
});
|
||||
|
||||
return new McpServer({
|
||||
name: 'finwise-finance',
|
||||
version: '0.1.0',
|
||||
description: 'Finwise Pro 财务接口 MCP 服务',
|
||||
tools: createFinanceTools(client),
|
||||
});
|
||||
};
|
||||
|
||||
createServer().start();
|
||||
@@ -1,7 +1,7 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
|
||||
VITE_GLOB_API_URL=http://192.168.9.149:5320/api
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<PageWrapper>
|
||||
<!-- 头部面包屑和操作 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<Breadcrumb>
|
||||
<Breadcrumb.Item>
|
||||
<a @click="router.back()">报销管理</a>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>报销详情</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
|
||||
<Space>
|
||||
<Button v-if="canEdit" type="primary" @click="handleEdit">
|
||||
<Icon icon="mdi:pencil" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button v-if="canSubmit" type="primary" @click="handleSubmit">
|
||||
<Icon icon="mdi:send" class="mr-1" />
|
||||
提交审批
|
||||
</Button>
|
||||
<Button v-if="canRevoke" danger @click="handleRevoke">
|
||||
<Icon icon="mdi:undo" class="mr-1" />
|
||||
撤回
|
||||
</Button>
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="handleMenuClick">
|
||||
<Menu.Item key="export">
|
||||
<Icon icon="mdi:download" class="mr-2" />
|
||||
导出PDF
|
||||
</Menu.Item>
|
||||
<Menu.Item key="print">
|
||||
<Icon icon="mdi:printer" class="mr-2" />
|
||||
打印
|
||||
</Menu.Item>
|
||||
<Menu.Item key="copy">
|
||||
<Icon icon="mdi:content-copy" class="mr-2" />
|
||||
复制
|
||||
</Menu.Item>
|
||||
<Menu.Divider v-if="canDelete" />
|
||||
<Menu.Item v-if="canDelete" key="delete" danger>
|
||||
<Icon icon="mdi:delete" class="mr-2" />
|
||||
删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button>
|
||||
更多操作
|
||||
<Icon icon="mdi:chevron-down" class="ml-1" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<!-- 报销单基本信息 -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center mb-4">
|
||||
<h2 class="text-2xl font-bold mr-4">{{ reimbursement.reimbursementNo }}</h2>
|
||||
<Tag :color="getStatusColor(reimbursement.status)" class="text-base px-3 py-1">
|
||||
<Icon :icon="getStatusIcon(reimbursement.status)" class="mr-1" />
|
||||
{{ getStatusText(reimbursement.status) }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Descriptions :column="3" bordered>
|
||||
<Descriptions.Item label="申请人">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: '#1890ff' }">
|
||||
{{ reimbursement.applicant.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<div class="font-semibold">{{ reimbursement.applicant }}</div>
|
||||
<div class="text-xs text-gray-400">{{ reimbursement.department }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{{ reimbursement.applyDate }}</Descriptions.Item>
|
||||
<Descriptions.Item label="报销金额">
|
||||
<span class="text-2xl font-bold text-red-600">¥{{ formatNumber(reimbursement.amount) }}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="费用类型" :span="2">
|
||||
<Space>
|
||||
<Tag v-for="cat in reimbursement.categories" :key="cat" color="blue">{{ cat }}</Tag>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="费用项数">{{ reimbursement.items.length }} 项</Descriptions.Item>
|
||||
<Descriptions.Item label="报销事由" :span="3">
|
||||
{{ reimbursement.reason }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" :span="3">
|
||||
{{ reimbursement.notes || '无' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批进度 -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">审批进度</h3>
|
||||
<Steps :current="currentStep" :status="stepsStatus">
|
||||
<Steps.Step
|
||||
v-for="(step, index) in approvalSteps"
|
||||
:key="index"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon v-if="step.status === 'finish'" icon="mdi:check-circle" class="text-green-500" />
|
||||
<Icon v-else-if="step.status === 'error'" icon="mdi:close-circle" class="text-red-500" />
|
||||
<Icon v-else-if="step.status === 'process'" icon="mdi:clock-outline" class="text-blue-500" />
|
||||
<Icon v-else icon="mdi:circle-outline" class="text-gray-400" />
|
||||
</template>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<Card title="费用明细" class="mb-4">
|
||||
<Table
|
||||
:columns="itemColumns"
|
||||
:dataSource="reimbursement.items"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1000 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'index'">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'category'">
|
||||
<Tag color="blue">{{ record.category }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold">¥{{ formatNumber(record.amount) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'receipt'">
|
||||
<Tag :color="record.hasReceipt ? 'success' : 'warning'">
|
||||
{{ record.hasReceipt ? '已上传' : '未上传' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
<template #summary>
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell :index="0" :colSpan="5" class="text-right font-bold">
|
||||
合计金额:
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell :index="5" class="font-bold text-lg text-red-600">
|
||||
¥{{ formatNumber(totalAmount) }}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell :index="6" />
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 附件列表 -->
|
||||
<Card title="附件资料" class="mb-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="(attachment, index) in reimbursement.attachments"
|
||||
:key="index"
|
||||
class="border rounded-lg p-3 hover:shadow-md transition-shadow cursor-pointer"
|
||||
@click="previewAttachment(attachment)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
:icon="getFileIcon(attachment.type)"
|
||||
class="text-3xl mr-3"
|
||||
:style="{ color: getFileColor(attachment.type) }"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="text-sm font-semibold truncate">{{ attachment.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ attachment.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-if="reimbursement.attachments.length === 0" description="暂无附件" />
|
||||
</Card>
|
||||
|
||||
<!-- 审批记录 -->
|
||||
<Card title="审批记录" class="mb-4">
|
||||
<Timeline>
|
||||
<Timeline.Item
|
||||
v-for="(record, index) in approvalRecords"
|
||||
:key="index"
|
||||
:color="getTimelineColor(record.action)"
|
||||
>
|
||||
<template #dot>
|
||||
<Icon :icon="getActionIcon(record.action)" class="text-lg" />
|
||||
</template>
|
||||
<div class="pb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.operator) }">
|
||||
{{ record.operator.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<span class="font-semibold">{{ record.operator }}</span>
|
||||
<span class="text-gray-500 ml-2">{{ record.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm">{{ record.time }}</span>
|
||||
</div>
|
||||
<div class="ml-10">
|
||||
<Tag :color="getActionColor(record.action)">{{ getActionText(record.action) }}</Tag>
|
||||
<div v-if="record.comment" class="mt-2 text-gray-600 bg-gray-50 p-3 rounded">
|
||||
{{ record.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Card>
|
||||
|
||||
<!-- 审批操作区(仅审批人可见) -->
|
||||
<Card v-if="showApprovalActions" title="审批操作" class="mb-4">
|
||||
<div class="max-w-2xl">
|
||||
<Form :model="approvalForm" layout="vertical">
|
||||
<Form.Item label="审批意见">
|
||||
<Input.TextArea
|
||||
v-model:value="approvalForm.comment"
|
||||
:rows="4"
|
||||
placeholder="请输入审批意见..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space size="large">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleApprove('approved')"
|
||||
:loading="approving"
|
||||
>
|
||||
<Icon icon="mdi:check-circle" class="mr-1" />
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="large"
|
||||
@click="handleApprove('rejected')"
|
||||
:loading="approving"
|
||||
>
|
||||
<Icon icon="mdi:close-circle" class="mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button size="large" @click="handleApprove('transfer')">
|
||||
<Icon icon="mdi:share" class="mr-1" />
|
||||
转交
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
|
||||
<!-- 附件预览Modal -->
|
||||
<Modal
|
||||
v-model:open="previewVisible"
|
||||
:title="previewFile?.name"
|
||||
width="80%"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="flex items-center justify-center h-96">
|
||||
<img v-if="isImage(previewFile?.type)" :src="previewFile?.url" class="max-h-full" />
|
||||
<div v-else class="text-center">
|
||||
<Icon :icon="getFileIcon(previewFile?.type)" class="text-8xl mb-4" />
|
||||
<p>{{ previewFile?.name }}</p>
|
||||
<Button type="primary" class="mt-4" @click="downloadFile(previewFile)">
|
||||
<Icon icon="mdi:download" class="mr-1" />
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { PageWrapper } from '@vben/common-ui';
|
||||
import {
|
||||
Card, Button, Space, Tag, Descriptions, Steps, Table, Timeline,
|
||||
Avatar, Breadcrumb, Dropdown, Menu, Form, Input, Modal, Empty, message
|
||||
} from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
defineOptions({ name: 'ReimbursementDetail' });
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewFile = ref<any>(null);
|
||||
const approving = ref(false);
|
||||
|
||||
// 审批表单
|
||||
const approvalForm = ref({
|
||||
comment: ''
|
||||
});
|
||||
|
||||
// 模拟报销单数据
|
||||
const reimbursement = ref({
|
||||
id: '1',
|
||||
reimbursementNo: 'RE202501001',
|
||||
applicant: '张三',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-08',
|
||||
amount: 3850.00,
|
||||
categories: ['差旅', '餐饮', '交通'],
|
||||
reason: '客户现场技术支持差旅费用报销',
|
||||
notes: '北京客户现场,为期3天的技术支持工作',
|
||||
status: 'pending',
|
||||
items: [
|
||||
{
|
||||
key: '1',
|
||||
date: '2025-01-05',
|
||||
category: '交通',
|
||||
description: '北京往返高铁票',
|
||||
amount: 1200.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
date: '2025-01-05',
|
||||
category: '住宿',
|
||||
description: '北京希尔顿酒店 2晚',
|
||||
amount: 1800.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
date: '2025-01-06',
|
||||
category: '餐饮',
|
||||
description: '客户商务晚餐',
|
||||
amount: 650.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
date: '2025-01-07',
|
||||
category: '交通',
|
||||
description: '北京市内打车费用',
|
||||
amount: 200.00,
|
||||
hasReceipt: true
|
||||
}
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
name: '高铁票.jpg',
|
||||
type: 'image',
|
||||
size: '2.5MB',
|
||||
url: 'https://via.placeholder.com/800x600'
|
||||
},
|
||||
{
|
||||
name: '酒店发票.pdf',
|
||||
type: 'pdf',
|
||||
size: '1.2MB',
|
||||
url: ''
|
||||
},
|
||||
{
|
||||
name: '餐饮发票.jpg',
|
||||
type: 'image',
|
||||
size: '1.8MB',
|
||||
url: 'https://via.placeholder.com/800x600'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 审批步骤
|
||||
const approvalSteps = ref([
|
||||
{
|
||||
title: '提交申请',
|
||||
description: '张三 · 2025-01-08 09:30',
|
||||
status: 'finish'
|
||||
},
|
||||
{
|
||||
title: '部门经理审批',
|
||||
description: '李经理 · 审批中',
|
||||
status: 'process'
|
||||
},
|
||||
{
|
||||
title: '财务审核',
|
||||
description: '待审核',
|
||||
status: 'wait'
|
||||
},
|
||||
{
|
||||
title: '总经理审批',
|
||||
description: '待审批',
|
||||
status: 'wait'
|
||||
},
|
||||
{
|
||||
title: '财务支付',
|
||||
description: '待支付',
|
||||
status: 'wait'
|
||||
}
|
||||
]);
|
||||
|
||||
// 审批记录
|
||||
const approvalRecords = ref([
|
||||
{
|
||||
operator: '张三',
|
||||
role: '申请人',
|
||||
action: 'submit',
|
||||
time: '2025-01-08 09:30:00',
|
||||
comment: '提交报销申请'
|
||||
},
|
||||
{
|
||||
operator: '李经理',
|
||||
role: '部门经理',
|
||||
action: 'review',
|
||||
time: '2025-01-08 14:20:00',
|
||||
comment: '正在审核中,请补充商务晚餐的详细说明'
|
||||
}
|
||||
]);
|
||||
|
||||
// 费用明细表格列
|
||||
const itemColumns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '费用类型',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '费用说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '发票状态',
|
||||
dataIndex: 'receipt',
|
||||
key: 'receipt',
|
||||
width: 100
|
||||
}
|
||||
];
|
||||
|
||||
// 计算属性
|
||||
const totalAmount = computed(() => {
|
||||
return reimbursement.value.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
});
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const finishIndex = approvalSteps.value.findIndex(step => step.status === 'process');
|
||||
return finishIndex >= 0 ? finishIndex : approvalSteps.value.length;
|
||||
});
|
||||
|
||||
const stepsStatus = computed(() => {
|
||||
if (reimbursement.value.status === 'rejected') return 'error';
|
||||
if (reimbursement.value.status === 'paid') return 'finish';
|
||||
return 'process';
|
||||
});
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return reimbursement.value.status === 'draft';
|
||||
});
|
||||
|
||||
const canRevoke = computed(() => {
|
||||
return reimbursement.value.status === 'pending';
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
|
||||
});
|
||||
|
||||
const showApprovalActions = computed(() => {
|
||||
// 检查URL参数或当前用户是否为审批人
|
||||
return route.query.action === 'approve' && reimbursement.value.status === 'pending';
|
||||
});
|
||||
|
||||
// 方法
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'pending': 'processing',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'paid': 'success'
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'draft': 'mdi:file-document-edit-outline',
|
||||
'pending': 'mdi:clock-outline',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'paid': 'mdi:cash-check'
|
||||
};
|
||||
return iconMap[status] || 'mdi:help-circle';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'pending': '待审批',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'paid': '已支付'
|
||||
};
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'image': 'mdi:file-image',
|
||||
'pdf': 'mdi:file-pdf-box',
|
||||
'excel': 'mdi:file-excel',
|
||||
'word': 'mdi:file-word'
|
||||
};
|
||||
return iconMap[type] || 'mdi:file-document';
|
||||
};
|
||||
|
||||
const getFileColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'image': '#52c41a',
|
||||
'pdf': '#f5222d',
|
||||
'excel': '#13c2c2',
|
||||
'word': '#1890ff'
|
||||
};
|
||||
return colorMap[type] || '#666';
|
||||
};
|
||||
|
||||
const isImage = (type?: string) => {
|
||||
return type === 'image';
|
||||
};
|
||||
|
||||
const getTimelineColor = (action: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'submit': 'blue',
|
||||
'review': 'orange',
|
||||
'approved': 'green',
|
||||
'rejected': 'red',
|
||||
'transfer': 'purple'
|
||||
};
|
||||
return colorMap[action] || 'gray';
|
||||
};
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'submit': 'mdi:send',
|
||||
'review': 'mdi:eye',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'transfer': 'mdi:share'
|
||||
};
|
||||
return iconMap[action] || 'mdi:circle';
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'submit': 'blue',
|
||||
'review': 'orange',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'transfer': 'purple'
|
||||
};
|
||||
return colorMap[action] || 'default';
|
||||
};
|
||||
|
||||
const getActionText = (action: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'submit': '提交申请',
|
||||
'review': '审核中',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'transfer': '转交'
|
||||
};
|
||||
return textMap[action] || action;
|
||||
};
|
||||
|
||||
const getRandomColor = (str: string) => {
|
||||
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1'];
|
||||
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
// 事件处理
|
||||
const handleEdit = () => {
|
||||
router.push(`/reimbursement/create?id=${reimbursement.value.id}&mode=edit`);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
Modal.confirm({
|
||||
title: '确认提交审批',
|
||||
content: '提交后将无法修改,是否确认提交审批?',
|
||||
onOk: () => {
|
||||
message.success('提交成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevoke = () => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '是否确认撤回此报销单?',
|
||||
onOk: () => {
|
||||
message.success('撤回成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case 'export':
|
||||
message.info('正在导出PDF...');
|
||||
break;
|
||||
case 'print':
|
||||
window.print();
|
||||
break;
|
||||
case 'copy':
|
||||
message.success('复制成功');
|
||||
break;
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,是否确认删除?',
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('删除成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const previewAttachment = (attachment: any) => {
|
||||
previewFile.value = attachment;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const downloadFile = (file: any) => {
|
||||
message.info(`正在下载 ${file.name}...`);
|
||||
};
|
||||
|
||||
const handleApprove = async (action: string) => {
|
||||
if (!approvalForm.value.comment && action === 'rejected') {
|
||||
message.warning('拒绝时必须填写审批意见');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: action === 'approved' ? '确认通过' : action === 'rejected' ? '确认拒绝' : '确认转交',
|
||||
content: `是否确认${action === 'approved' ? '通过' : action === 'rejected' ? '拒绝' : '转交'}此报销申请?`,
|
||||
onOk: async () => {
|
||||
approving.value = true;
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
approving.value = false;
|
||||
message.success('操作成功');
|
||||
router.back();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 根据路由参数加载报销单详情
|
||||
const id = route.params.id;
|
||||
console.log('加载报销单详情:', id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
font-weight: 600;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:deep(.ant-steps-item-description) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,780 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<PageWrapper title="报销管理" content="全面的报销单管理系统,支持创建、审批、追踪报销流程">
|
||||
<!-- 顶部操作栏 -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Input.Search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索报销单号、申请人、事由..."
|
||||
style="width: 350px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<Select v-model:value="filterStatus" style="width: 140px" placeholder="状态筛选" @change="onFilterChange">
|
||||
<Select.Option value="">全部状态</Select.Option>
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="pending">待审批</Select.Option>
|
||||
<Select.Option value="approved">已通过</Select.Option>
|
||||
<Select.Option value="rejected">已拒绝</Select.Option>
|
||||
<Select.Option value="paid">已支付</Select.Option>
|
||||
<Select.Option value="cancelled">已取消</Select.Option>
|
||||
</Select>
|
||||
<Select v-model:value="filterDepartment" style="width: 150px" placeholder="部门筛选" @change="onFilterChange">
|
||||
<Select.Option value="">全部部门</Select.Option>
|
||||
<Select.Option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</Select.Option>
|
||||
</Select>
|
||||
<RangePicker v-model:value="dateFilter" placeholder="['申请日期', '结束日期']" @change="onDateChange" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button type="primary" @click="goToCreate">
|
||||
<Icon icon="mdi:plus" class="mr-1" />
|
||||
创建报销单
|
||||
</Button>
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="handleExport">
|
||||
<Menu.Item key="excel">导出Excel</Menu.Item>
|
||||
<Menu.Item key="pdf">导出PDF</Menu.Item>
|
||||
<Menu.Item key="selected">导出选中</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button>
|
||||
<Icon icon="mdi:download" class="mr-1" />
|
||||
导出
|
||||
<Icon icon="mdi:chevron-down" class="ml-1" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷筛选标签 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-500 text-sm">快捷筛选:</span>
|
||||
<Tag
|
||||
:color="quickFilter === '' ? 'blue' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = ''; loadReimbursements()"
|
||||
>
|
||||
全部 ({{ statistics.total }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'my' ? 'blue' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'my'; loadReimbursements()"
|
||||
>
|
||||
我的报销 ({{ statistics.myTotal }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'pending' ? 'orange' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'pending'; loadReimbursements()"
|
||||
>
|
||||
待我审批 ({{ statistics.pendingApproval }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'approved' ? 'green' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'approved'; loadReimbursements()"
|
||||
>
|
||||
已通过 ({{ statistics.approved }})
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="本月报销总额"
|
||||
:value="statistics.monthTotal"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #1890ff"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">较上月 +15.2%</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="待审批金额"
|
||||
:value="statistics.pendingAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #faad14"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">{{ statistics.pendingApproval }} 笔待审</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="已支付金额"
|
||||
:value="statistics.paidAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #52c41a"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">{{ statistics.paid }} 笔已支付</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="平均处理时长"
|
||||
:value="statistics.avgProcessTime"
|
||||
suffix="天"
|
||||
value-style="color: #722ed1"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">审批效率良好</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 报销单列表 -->
|
||||
<Card title="报销单列表">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Tooltip title="刷新数据">
|
||||
<Button @click="loadReimbursements" :loading="loading">
|
||||
<Icon icon="mdi:refresh" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="列设置">
|
||||
<Button @click="showColumnSetting = true">
|
||||
<Icon icon="mdi:cog" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:dataSource="filteredReimbursements"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1500 }"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total, range) => `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
}"
|
||||
:rowSelection="rowSelection"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 自定义列模板 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'reimbursementNo'">
|
||||
<a @click="viewDetail(record)" class="text-blue-600 hover:text-blue-800">
|
||||
{{ record.reimbursementNo }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold text-lg" :class="getAmountColor(record.amount)">
|
||||
¥{{ formatNumber(record.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<Tag :color="getStatusColor(record.status)">
|
||||
<Icon :icon="getStatusIcon(record.status)" class="mr-1" />
|
||||
{{ getStatusText(record.status) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'applicant'">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.applicant) }">
|
||||
{{ record.applicant.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<div>{{ record.applicant }}</div>
|
||||
<div class="text-xs text-gray-400">{{ record.department }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'items'">
|
||||
<div class="text-xs">
|
||||
<div>{{ record.itemsCount }} 项费用</div>
|
||||
<div class="text-gray-400">{{ record.categories.join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'progress'">
|
||||
<Tooltip :title="`${record.progress}% 完成`">
|
||||
<Progress
|
||||
:percent="record.progress"
|
||||
:status="record.status === 'rejected' ? 'exception' : 'normal'"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'approver'">
|
||||
<div class="text-sm">
|
||||
<div>{{ record.currentApprover || '-' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ record.approvalStep || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<Space>
|
||||
<Tooltip title="查看详情">
|
||||
<Button type="link" size="small" @click="viewDetail(record)">
|
||||
<Icon icon="mdi:eye" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="canEdit(record)" title="编辑">
|
||||
<Button type="link" size="small" @click="editReimbursement(record)">
|
||||
<Icon icon="mdi:pencil" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="canApprove(record)" title="审批">
|
||||
<Button type="link" size="small" @click="approveReimbursement(record)">
|
||||
<Icon icon="mdi:check-circle" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="({ key }) => handleAction(key, record)">
|
||||
<Menu.Item v-if="canSubmit(record)" key="submit">
|
||||
<Icon icon="mdi:send" class="mr-2" />提交审批
|
||||
</Menu.Item>
|
||||
<Menu.Item v-if="canRevoke(record)" key="revoke">
|
||||
<Icon icon="mdi:undo" class="mr-2" />撤回
|
||||
</Menu.Item>
|
||||
<Menu.Item key="export">
|
||||
<Icon icon="mdi:download" class="mr-2" />导出
|
||||
</Menu.Item>
|
||||
<Menu.Item key="copy">
|
||||
<Icon icon="mdi:content-copy" class="mr-2" />复制
|
||||
</Menu.Item>
|
||||
<Menu.Divider v-if="canDelete(record)" />
|
||||
<Menu.Item v-if="canDelete(record)" key="delete" danger>
|
||||
<Icon icon="mdi:delete" class="mr-2" />删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button type="link" size="small">
|
||||
<Icon icon="mdi:dots-vertical" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 批量操作浮动按钮 -->
|
||||
<div v-if="selectedRowKeys.length > 0" class="fixed bottom-8 right-8 z-50">
|
||||
<Card class="shadow-2xl">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm">已选择 <span class="font-bold text-blue-600">{{ selectedRowKeys.length }}</span> 项</span>
|
||||
<Button type="primary" @click="batchApprove" :disabled="!canBatchApprove">
|
||||
批量审批
|
||||
</Button>
|
||||
<Button @click="batchExport">批量导出</Button>
|
||||
<Button danger @click="batchDelete" :disabled="!canBatchDelete">批量删除</Button>
|
||||
<Button @click="selectedRowKeys = []">取消选择</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { PageWrapper } from '@vben/common-ui';
|
||||
import {
|
||||
Card, Table, Button, Input, Select, RangePicker, Space, Tag,
|
||||
Tooltip, Dropdown, Menu, Statistic, Progress, Avatar, Modal, message
|
||||
} from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'ReimbursementManagement' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 状态管理
|
||||
const searchText = ref('');
|
||||
const filterStatus = ref('');
|
||||
const filterDepartment = ref('');
|
||||
const dateFilter = ref();
|
||||
const quickFilter = ref('');
|
||||
const loading = ref(false);
|
||||
const showColumnSetting = ref(false);
|
||||
const selectedRowKeys = ref<string[]>([]);
|
||||
|
||||
// 分页
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// 部门列表
|
||||
const departments = ref(['技术部', '市场部', '财务部', '人事部', '行政部', '销售部']);
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref({
|
||||
total: 156,
|
||||
myTotal: 23,
|
||||
pendingApproval: 8,
|
||||
approved: 98,
|
||||
monthTotal: 285690.50,
|
||||
pendingAmount: 45230.00,
|
||||
paidAmount: 240460.50,
|
||||
paid: 98,
|
||||
avgProcessTime: 2.5
|
||||
});
|
||||
|
||||
// 报销单数据
|
||||
const reimbursements = ref([
|
||||
{
|
||||
key: '1',
|
||||
reimbursementNo: 'RE202501001',
|
||||
applicant: '张三',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-08',
|
||||
amount: 3850.00,
|
||||
itemsCount: 5,
|
||||
categories: ['差旅', '餐饮'],
|
||||
reason: '客户现场技术支持差旅费',
|
||||
status: 'pending',
|
||||
progress: 50,
|
||||
currentApprover: '李经理',
|
||||
approvalStep: '部门经理审批',
|
||||
attachments: 3,
|
||||
createTime: '2025-01-08 09:30:00',
|
||||
updateTime: '2025-01-08 14:20:00'
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
reimbursementNo: 'RE202501002',
|
||||
applicant: '李四',
|
||||
department: '市场部',
|
||||
applyDate: '2025-01-07',
|
||||
amount: 12600.00,
|
||||
itemsCount: 8,
|
||||
categories: ['市场活动', '餐饮', '交通'],
|
||||
reason: '产品发布会活动费用',
|
||||
status: 'approved',
|
||||
progress: 100,
|
||||
currentApprover: '-',
|
||||
approvalStep: '已完成',
|
||||
attachments: 15,
|
||||
createTime: '2025-01-07 10:15:00',
|
||||
updateTime: '2025-01-08 16:40:00'
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
reimbursementNo: 'RE202501003',
|
||||
applicant: '王五',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-09',
|
||||
amount: 5200.00,
|
||||
itemsCount: 3,
|
||||
categories: ['办公用品', '设备'],
|
||||
reason: '团队办公设备采购',
|
||||
status: 'draft',
|
||||
progress: 0,
|
||||
currentApprover: '-',
|
||||
approvalStep: '未提交',
|
||||
attachments: 2,
|
||||
createTime: '2025-01-09 11:00:00',
|
||||
updateTime: '2025-01-09 11:00:00'
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
reimbursementNo: 'RE202501004',
|
||||
applicant: '赵六',
|
||||
department: '销售部',
|
||||
applyDate: '2025-01-06',
|
||||
amount: 8900.00,
|
||||
itemsCount: 6,
|
||||
categories: ['差旅', '住宿', '交通'],
|
||||
reason: '客户拜访及商务洽谈',
|
||||
status: 'paid',
|
||||
progress: 100,
|
||||
currentApprover: '-',
|
||||
approvalStep: '已支付',
|
||||
attachments: 8,
|
||||
createTime: '2025-01-06 08:45:00',
|
||||
updateTime: '2025-01-07 10:30:00'
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
reimbursementNo: 'RE202501005',
|
||||
applicant: '陈七',
|
||||
department: '市场部',
|
||||
applyDate: '2025-01-05',
|
||||
amount: 2800.00,
|
||||
itemsCount: 4,
|
||||
categories: ['餐饮', '礼品'],
|
||||
reason: '客户接待费用',
|
||||
status: 'rejected',
|
||||
progress: 30,
|
||||
currentApprover: '王总监',
|
||||
approvalStep: '财务审批已拒绝',
|
||||
attachments: 4,
|
||||
createTime: '2025-01-05 14:20:00',
|
||||
updateTime: '2025-01-05 17:10:00'
|
||||
}
|
||||
]);
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '报销单号',
|
||||
dataIndex: 'reimbursementNo',
|
||||
key: 'reimbursementNo',
|
||||
width: 140,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
dataIndex: 'applicant',
|
||||
key: 'applicant',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '申请日期',
|
||||
dataIndex: 'applyDate',
|
||||
key: 'applyDate',
|
||||
width: 110,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '报销金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 130,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '费用明细',
|
||||
dataIndex: 'items',
|
||||
key: 'items',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '事由',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
ellipsis: true,
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
filters: [
|
||||
{ text: '草稿', value: 'draft' },
|
||||
{ text: '待审批', value: 'pending' },
|
||||
{ text: '已通过', value: 'approved' },
|
||||
{ text: '已拒绝', value: 'rejected' },
|
||||
{ text: '已支付', value: 'paid' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '当前审批人',
|
||||
dataIndex: 'approver',
|
||||
key: 'approver',
|
||||
width: 130
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right'
|
||||
}
|
||||
];
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredReimbursements = computed(() => {
|
||||
let filtered = reimbursements.value;
|
||||
|
||||
// 搜索过滤
|
||||
if (searchText.value) {
|
||||
const search = searchText.value.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.reimbursementNo.toLowerCase().includes(search) ||
|
||||
r.applicant.toLowerCase().includes(search) ||
|
||||
r.reason.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filterStatus.value) {
|
||||
filtered = filtered.filter(r => r.status === filterStatus.value);
|
||||
}
|
||||
|
||||
// 部门过滤
|
||||
if (filterDepartment.value) {
|
||||
filtered = filtered.filter(r => r.department === filterDepartment.value);
|
||||
}
|
||||
|
||||
// 快捷过滤
|
||||
if (quickFilter.value === 'my') {
|
||||
// 模拟:只显示当前用户的报销单
|
||||
filtered = filtered.filter(r => r.applicant === '张三');
|
||||
} else if (quickFilter.value === 'pending') {
|
||||
filtered = filtered.filter(r => r.status === 'pending');
|
||||
} else if (quickFilter.value === 'approved') {
|
||||
filtered = filtered.filter(r => r.status === 'approved');
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys: string[]) => {
|
||||
selectedRowKeys.value = keys;
|
||||
}
|
||||
};
|
||||
|
||||
// 权限判断
|
||||
const canEdit = (record: any) => {
|
||||
return record.status === 'draft' || record.status === 'rejected';
|
||||
};
|
||||
|
||||
const canApprove = (record: any) => {
|
||||
return record.status === 'pending';
|
||||
};
|
||||
|
||||
const canSubmit = (record: any) => {
|
||||
return record.status === 'draft';
|
||||
};
|
||||
|
||||
const canRevoke = (record: any) => {
|
||||
return record.status === 'pending';
|
||||
};
|
||||
|
||||
const canDelete = (record: any) => {
|
||||
return record.status === 'draft' || record.status === 'rejected';
|
||||
};
|
||||
|
||||
const canBatchApprove = computed(() => {
|
||||
return selectedRowKeys.value.some(key => {
|
||||
const record = reimbursements.value.find(r => r.key === key);
|
||||
return record && record.status === 'pending';
|
||||
});
|
||||
});
|
||||
|
||||
const canBatchDelete = computed(() => {
|
||||
return selectedRowKeys.value.every(key => {
|
||||
const record = reimbursements.value.find(r => r.key === key);
|
||||
return record && (record.status === 'draft' || record.status === 'rejected');
|
||||
});
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const getAmountColor = (amount: number) => {
|
||||
if (amount > 10000) return 'text-red-600';
|
||||
if (amount > 5000) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'pending': 'processing',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'paid': 'success',
|
||||
'cancelled': 'default'
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'draft': 'mdi:file-document-edit-outline',
|
||||
'pending': 'mdi:clock-outline',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'paid': 'mdi:cash-check',
|
||||
'cancelled': 'mdi:cancel'
|
||||
};
|
||||
return iconMap[status] || 'mdi:help-circle';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'pending': '待审批',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'paid': '已支付',
|
||||
'cancelled': '已取消'
|
||||
};
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const getRandomColor = (str: string) => {
|
||||
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
|
||||
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
// 事件处理
|
||||
const onSearch = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const onFilterChange = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const onDateChange = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
||||
pagination.value = pag;
|
||||
console.log('表格变化:', pag, filters, sorter);
|
||||
};
|
||||
|
||||
const loadReimbursements = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
pagination.value.total = filteredReimbursements.value.length;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const goToCreate = () => {
|
||||
router.push('/reimbursement/create');
|
||||
};
|
||||
|
||||
const viewDetail = (record: any) => {
|
||||
router.push(`/reimbursement/detail/${record.key}`);
|
||||
};
|
||||
|
||||
const editReimbursement = (record: any) => {
|
||||
router.push(`/reimbursement/create?id=${record.key}&mode=edit`);
|
||||
};
|
||||
|
||||
const approveReimbursement = (record: any) => {
|
||||
router.push(`/reimbursement/detail/${record.key}?action=approve`);
|
||||
};
|
||||
|
||||
const handleAction = (key: string, record: any) => {
|
||||
switch (key) {
|
||||
case 'submit':
|
||||
Modal.confirm({
|
||||
title: '确认提交审批',
|
||||
content: `是否确认提交报销单 ${record.reimbursementNo} 进行审批?`,
|
||||
onOk: () => {
|
||||
message.success('提交成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'revoke':
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: `是否确认撤回报销单 ${record.reimbursementNo}?`,
|
||||
onOk: () => {
|
||||
message.success('撤回成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'export':
|
||||
message.info('正在导出...');
|
||||
break;
|
||||
case 'copy':
|
||||
message.success('复制成功');
|
||||
break;
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `是否确认删除报销单 ${record.reimbursementNo}?此操作不可恢复。`,
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('删除成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = ({ key }: { key: string }) => {
|
||||
message.info(`正在导出${key}格式...`);
|
||||
};
|
||||
|
||||
const batchApprove = () => {
|
||||
Modal.confirm({
|
||||
title: '批量审批',
|
||||
content: `是否确认批量审批选中的 ${selectedRowKeys.value.length} 个报销单?`,
|
||||
onOk: () => {
|
||||
message.success('批量审批成功');
|
||||
selectedRowKeys.value = [];
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const batchExport = () => {
|
||||
message.info(`正在导出选中的 ${selectedRowKeys.value.length} 个报销单...`);
|
||||
};
|
||||
|
||||
const batchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '批量删除',
|
||||
content: `是否确认删除选中的 ${selectedRowKeys.value.length} 个报销单?此操作不可恢复。`,
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('批量删除成功');
|
||||
selectedRowKeys.value = [];
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadReimbursements();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content-value) {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,13 @@
|
||||
import { requestClient } from '../request';
|
||||
|
||||
export namespace FinanceApi {
|
||||
export type TransactionStatus =
|
||||
| 'draft'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'paid';
|
||||
|
||||
// 货币类型
|
||||
export interface Currency {
|
||||
code: string;
|
||||
@@ -71,6 +78,38 @@ export namespace FinanceApi {
|
||||
createdAt: string;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
status: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: string;
|
||||
reviewNotes?: string;
|
||||
submittedBy?: string;
|
||||
approvedBy?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface MediaMessage {
|
||||
id: number;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
fileType: string;
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
forwardedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
available: boolean;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
// 创建交易的参数
|
||||
@@ -85,6 +124,34 @@ export namespace FinanceApi {
|
||||
project?: string;
|
||||
memo?: string;
|
||||
createdAt?: string;
|
||||
status?: TransactionStatus;
|
||||
reimbursementBatch?: string | null;
|
||||
reviewNotes?: string | null;
|
||||
submittedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: string | null;
|
||||
statusUpdatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateReimbursementParams {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
amount: number;
|
||||
currency?: string;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
transactionDate: string;
|
||||
description?: string;
|
||||
project?: string;
|
||||
memo?: string;
|
||||
createdAt?: string;
|
||||
status?: TransactionStatus;
|
||||
reimbursementBatch?: string | null;
|
||||
reviewNotes?: string | null;
|
||||
submittedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: string | null;
|
||||
statusUpdatedAt?: string;
|
||||
requester?: string | null;
|
||||
}
|
||||
|
||||
// 预算
|
||||
@@ -210,9 +277,21 @@ export namespace FinanceApi {
|
||||
*/
|
||||
export async function getTransactions(params?: {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
statuses?: TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.type) {
|
||||
query.type = params.type;
|
||||
}
|
||||
if (params?.statuses && params.statuses.length > 0) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params?.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return requestClient.get<Transaction[]>('/finance/transactions', {
|
||||
params,
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,6 +312,66 @@ export namespace FinanceApi {
|
||||
return requestClient.put<Transaction>(`/finance/transactions/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报销申请
|
||||
*/
|
||||
export async function getReimbursements(params?: {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
statuses?: TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.type) {
|
||||
query.type = params.type;
|
||||
}
|
||||
if (params?.statuses && params.statuses.length > 0) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params?.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return requestClient.get<Transaction[]>('/finance/reimbursements', {
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建报销申请
|
||||
*/
|
||||
export async function createReimbursement(
|
||||
data: CreateReimbursementParams,
|
||||
) {
|
||||
return requestClient.post<Transaction>('/finance/reimbursements', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新报销申请
|
||||
*/
|
||||
export async function updateReimbursement(
|
||||
id: number,
|
||||
data: Partial<CreateReimbursementParams>,
|
||||
) {
|
||||
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除报销申请
|
||||
*/
|
||||
export async function deleteReimbursement(id: number) {
|
||||
return requestClient.delete<{ message: string }>(
|
||||
`/finance/transactions/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复报销申请
|
||||
*/
|
||||
export async function restoreReimbursement(id: number) {
|
||||
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, {
|
||||
isDeleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除交易
|
||||
*/
|
||||
@@ -290,4 +429,30 @@ export namespace FinanceApi {
|
||||
isDeleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体消息
|
||||
*/
|
||||
export async function getMediaMessages(params?: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.limit) {
|
||||
query.limit = params.limit;
|
||||
}
|
||||
if (params?.fileTypes && params.fileTypes.length > 0) {
|
||||
query.types = params.fileTypes.join(',');
|
||||
}
|
||||
return requestClient.get<MediaMessage[]>('/finance/media', {
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条媒体消息详情
|
||||
*/
|
||||
export async function getMediaMessage(id: number) {
|
||||
return requestClient.get<MediaMessage>(`/finance/media/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ const flattenFinWiseProMenu = () => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -52,8 +52,7 @@ function flattenFinWiseProMenu() {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -55,10 +55,7 @@ router.afterEach(() => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if (
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise === 'true'
|
||||
)
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -79,6 +79,57 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '📈 报表分析',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceReimbursement',
|
||||
path: '/reimbursement',
|
||||
alias: ['/finance/reimbursement'],
|
||||
component: () => import('#/views/finance/reimbursement/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:file-document-outline',
|
||||
order: 8,
|
||||
title: '💼 报销管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementDetail',
|
||||
path: '/reimbursement/detail/:id',
|
||||
component: () => import('#/views/finance/reimbursement/detail.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'mdi:file-document',
|
||||
title: '报销详情',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementCreate',
|
||||
path: '/reimbursement/create',
|
||||
component: () => import('#/views/finance/reimbursement/create.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'mdi:plus-circle',
|
||||
title: '创建报销单',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementApproval',
|
||||
path: '/reimbursement/approval',
|
||||
component: () => import('#/views/finance/reimbursement/approval.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:checkbox-marked-circle-outline',
|
||||
order: 9,
|
||||
title: '📋 待审批',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementStatistics',
|
||||
path: '/reimbursement/statistics',
|
||||
component: () => import('#/views/finance/reimbursement/statistics.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:chart-bar',
|
||||
order: 10,
|
||||
title: '📊 报销统计',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceTools',
|
||||
path: '/tools',
|
||||
@@ -86,10 +137,21 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/finance/tools/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:tools',
|
||||
order: 8,
|
||||
order: 11,
|
||||
title: '🛠️ 财务工具',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceMedia',
|
||||
path: '/media',
|
||||
alias: ['/finance/media'],
|
||||
component: () => import('#/views/finance/media/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:folder-multiple-image',
|
||||
order: 12,
|
||||
title: '🖼️ 媒体中心',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceSettings',
|
||||
path: '/fin-settings',
|
||||
@@ -97,7 +159,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/finance/settings/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:cog',
|
||||
order: 9,
|
||||
order: 13,
|
||||
title: '⚙️ 系统设置',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,6 +13,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]);
|
||||
const transactions = ref<FinanceApi.Transaction[]>([]);
|
||||
const budgets = ref<FinanceApi.Budget[]>([]);
|
||||
const reimbursements = ref<FinanceApi.Transaction[]>([]);
|
||||
const mediaMessages = ref<FinanceApi.MediaMessage[]>([]);
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
@@ -22,6 +24,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
exchangeRates: false,
|
||||
transactions: false,
|
||||
budgets: false,
|
||||
reimbursements: false,
|
||||
mediaMessages: false,
|
||||
});
|
||||
|
||||
// 获取货币列表
|
||||
@@ -131,15 +135,32 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
}
|
||||
|
||||
// 获取交易列表
|
||||
async function fetchTransactions() {
|
||||
async function fetchTransactions(params?: {
|
||||
statuses?: FinanceApi.TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
loading.value.transactions = true;
|
||||
try {
|
||||
transactions.value = await FinanceApi.getTransactions();
|
||||
transactions.value = await FinanceApi.getTransactions(params);
|
||||
} finally {
|
||||
loading.value.transactions = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取媒体消息
|
||||
async function fetchMediaMessages(params?: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
}) {
|
||||
loading.value.mediaMessages = true;
|
||||
try {
|
||||
mediaMessages.value = await FinanceApi.getMediaMessages(params);
|
||||
} finally {
|
||||
loading.value.mediaMessages = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建交易
|
||||
async function createTransaction(data: FinanceApi.CreateTransactionParams) {
|
||||
const transaction = await FinanceApi.createTransaction(data);
|
||||
@@ -195,6 +216,80 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// 获取报销申请
|
||||
async function fetchReimbursements(params?: {
|
||||
statuses?: FinanceApi.TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
loading.value.reimbursements = true;
|
||||
try {
|
||||
reimbursements.value = await FinanceApi.getReimbursements(params);
|
||||
} finally {
|
||||
loading.value.reimbursements = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建报销申请
|
||||
async function createReimbursement(
|
||||
data: FinanceApi.CreateReimbursementParams,
|
||||
) {
|
||||
const payload = {
|
||||
...data,
|
||||
type: data.type ?? 'expense',
|
||||
currency: data.currency ?? 'CNY',
|
||||
status: data.status ?? 'pending',
|
||||
submittedBy: data.submittedBy ?? data.requester ?? null,
|
||||
};
|
||||
const reimbursement = await FinanceApi.createReimbursement(payload);
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 更新报销申请
|
||||
async function updateReimbursement(
|
||||
id: number,
|
||||
data: Partial<FinanceApi.CreateReimbursementParams>,
|
||||
) {
|
||||
const reimbursement = await FinanceApi.updateReimbursement(id, data);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
reimbursements.value[index] = reimbursement;
|
||||
} else {
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
}
|
||||
if (reimbursement.status === 'approved' || reimbursement.status === 'paid') {
|
||||
await Promise.all([fetchTransactions(), fetchAccounts()]);
|
||||
}
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 删除报销申请
|
||||
async function deleteReimbursement(id: number) {
|
||||
await FinanceApi.deleteReimbursement(id);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
const current = reimbursements.value[index];
|
||||
reimbursements.value[index] = {
|
||||
...current,
|
||||
isDeleted: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复报销申请
|
||||
async function restoreReimbursement(id: number) {
|
||||
const reimbursement = await FinanceApi.restoreReimbursement(id);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
reimbursements.value[index] = reimbursement;
|
||||
} else {
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
}
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 根据货币代码获取货币信息
|
||||
function getCurrencyByCode(code: string) {
|
||||
return currencies.value.find((c) => c.code === code);
|
||||
@@ -296,6 +391,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
exchangeRates,
|
||||
transactions,
|
||||
budgets,
|
||||
reimbursements,
|
||||
mediaMessages,
|
||||
loading,
|
||||
|
||||
// 方法
|
||||
@@ -307,10 +404,16 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
fetchAccounts,
|
||||
fetchExchangeRates,
|
||||
fetchTransactions,
|
||||
fetchMediaMessages,
|
||||
createTransaction,
|
||||
updateTransaction,
|
||||
softDeleteTransaction,
|
||||
restoreTransaction,
|
||||
fetchReimbursements,
|
||||
createReimbursement,
|
||||
updateReimbursement,
|
||||
deleteReimbursement,
|
||||
restoreReimbursement,
|
||||
fetchBudgets,
|
||||
createBudget,
|
||||
updateBudget,
|
||||
|
||||
@@ -268,11 +268,11 @@ const resetForm = () => {
|
||||
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
const symbolMap: Record<string, string> = {
|
||||
CNY: '¥',
|
||||
CNY: '$',
|
||||
THB: '฿',
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
JPY: '¥',
|
||||
JPY: '$',
|
||||
GBP: '£',
|
||||
HKD: 'HK$',
|
||||
KRW: '₩',
|
||||
|
||||
@@ -1,25 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'BillReminders' });
|
||||
|
||||
const showAddBill = ref(false);
|
||||
|
||||
// 今日账单(空数据)
|
||||
const todayBills = ref([]);
|
||||
|
||||
// 所有账单(空数据)
|
||||
const allBills = ref([]);
|
||||
|
||||
// 提醒设置
|
||||
const reminderSettings = ref({
|
||||
daysBefore: 3,
|
||||
smsEnabled: true,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
});
|
||||
|
||||
const saveReminderSettings = () => {
|
||||
console.log('保存提醒设置:', reminderSettings.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🔔 账单提醒</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🔔 账单提醒</h1>
|
||||
<p class="text-gray-600">智能账单管理,从此不错过任何缴费</p>
|
||||
</div>
|
||||
|
||||
<!-- 今日提醒 -->
|
||||
<Card class="mb-6" title="📅 今日待缴账单">
|
||||
<div v-if="todayBills.length === 0" class="text-center py-8">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<p class="text-green-600 font-medium">今天没有待缴账单</p>
|
||||
<div v-if="todayBills.length === 0" class="py-8 text-center">
|
||||
<div class="mb-4 text-6xl">✅</div>
|
||||
<p class="font-medium text-green-600">今天没有待缴账单</p>
|
||||
<p class="text-sm text-gray-500">享受无忧的一天</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="bill in todayBills" :key="bill.id" class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div
|
||||
v-for="bill in todayBills"
|
||||
:key="bill.id"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ bill.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium text-red-800">{{ bill.name }}</p>
|
||||
<p class="text-sm text-red-600">今天到期 · ¥{{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="text-sm text-red-600">
|
||||
今天到期 · ${{ bill.amount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
@@ -37,27 +71,33 @@
|
||||
<Button type="primary" @click="showAddBill = true">➕ 添加账单</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="allBills.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">📱</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无账单记录</h3>
|
||||
<p class="text-gray-500 mb-6">添加您的常用账单,系统将自动提醒</p>
|
||||
<div v-if="allBills.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">📱</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无账单记录</h3>
|
||||
<p class="mb-6 text-gray-500">添加您的常用账单,系统将自动提醒</p>
|
||||
<Button type="primary" size="large" @click="showAddBill = true">
|
||||
➕ 添加第一个账单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="bill in allBills" :key="bill.id" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
v-for="bill in allBills"
|
||||
:key="bill.id"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ bill.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ bill.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ bill.provider }} · 每{{ bill.cycle }}缴费</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ bill.provider }} · 每{{ bill.cycle }}缴费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">¥{{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="font-semibold">${{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="text-sm text-gray-500">下次: {{ bill.nextDue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,11 +106,13 @@
|
||||
</Card>
|
||||
|
||||
<!-- 账单统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card title="📊 月度账单统计">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📈</div>
|
||||
<div class="mb-2 text-4xl">📈</div>
|
||||
<p class="text-gray-600">月度账单趋势</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,56 +120,37 @@
|
||||
|
||||
<Card title="⏰ 提醒设置">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>提前提醒天数</span>
|
||||
<InputNumber v-model:value="reminderSettings.daysBefore" :min="1" :max="30" />
|
||||
<InputNumber
|
||||
v-model:value="reminderSettings.daysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>短信提醒</span>
|
||||
<Switch v-model:checked="reminderSettings.smsEnabled" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>邮件提醒</span>
|
||||
<Switch v-model:checked="reminderSettings.emailEnabled" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>应用通知</span>
|
||||
<Switch v-model:checked="reminderSettings.pushEnabled" />
|
||||
</div>
|
||||
<Button type="primary" block @click="saveReminderSettings">保存设置</Button>
|
||||
<Button type="primary" block @click="saveReminderSettings">
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Button, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'BillReminders' });
|
||||
|
||||
const showAddBill = ref(false);
|
||||
|
||||
// 今日账单(空数据)
|
||||
const todayBills = ref([]);
|
||||
|
||||
// 所有账单(空数据)
|
||||
const allBills = ref([]);
|
||||
|
||||
// 提醒设置
|
||||
const reminderSettings = ref({
|
||||
daysBefore: 3,
|
||||
smsEnabled: true,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true
|
||||
});
|
||||
|
||||
const saveReminderSettings = () => {
|
||||
console.log('保存提醒设置:', reminderSettings.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -370,7 +370,7 @@ const setBudget = (category: any) => {
|
||||
<div class="rounded-lg bg-purple-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">预算总额</p>
|
||||
<p class="text-xl font-bold text-purple-600">
|
||||
¥{{ categoryStats.budgetTotal.toLocaleString() }}
|
||||
${{ categoryStats.budgetTotal.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ const baseCurrencySymbol = computed(() => {
|
||||
const baseCurrency = financeStore.currencies.find(
|
||||
(currency) => currency.isBase,
|
||||
);
|
||||
return baseCurrency?.symbol || '¥';
|
||||
return baseCurrency?.symbol || '$';
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
@@ -203,9 +203,9 @@ const trendChartData = computed(() => {
|
||||
bucketKeys.forEach((key) => {
|
||||
const bucket = bucketMap.get(key) ?? { income: 0, expense: 0 };
|
||||
const label = useMonthlyBucket
|
||||
? (english
|
||||
? english
|
||||
? dayjs(key).format('MMM')
|
||||
: dayjs(key).format('MM月'))
|
||||
: dayjs(key).format('MM月')
|
||||
: dayjs(key).format('MM-DD');
|
||||
labels.push(label);
|
||||
const income = Number(bucket.income.toFixed(2));
|
||||
@@ -1039,7 +1039,9 @@ onMounted(async () => {
|
||||
:style="{ width: `${item.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-12 text-right text-xs text-gray-500">{{ item.percentage }}%</span>
|
||||
<span class="w-12 text-right text-xs text-gray-500"
|
||||
>{{ item.percentage }}%</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} {{ isEnglish ? 'records' : '笔交易' }}
|
||||
|
||||
@@ -1,232 +1,20 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">📱 费用追踪</h1>
|
||||
<p class="text-gray-600">智能费用追踪,支持小票OCR识别和自动分类</p>
|
||||
</div>
|
||||
|
||||
<!-- 快速添加费用 -->
|
||||
<Card class="mb-6" title="⚡ 快速记录">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- 拍照记录 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 cursor-pointer" @click="openCamera">
|
||||
<div class="text-4xl mb-3">📷</div>
|
||||
<h3 class="font-medium mb-2">拍照记录</h3>
|
||||
<p class="text-sm text-gray-500">拍摄小票,自动识别金额和商家</p>
|
||||
</div>
|
||||
|
||||
<!-- 语音记录 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 cursor-pointer" @click="startVoiceRecord">
|
||||
<div class="text-4xl mb-3">🎤</div>
|
||||
<h3 class="font-medium mb-2">语音记录</h3>
|
||||
<p class="text-sm text-gray-500">说出消费内容,智能转换为记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-purple-400 cursor-pointer" @click="showQuickAdd = true">
|
||||
<div class="text-4xl mb-3">✍️</div>
|
||||
<h3 class="font-medium mb-2">手动输入</h3>
|
||||
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 今日费用汇总 -->
|
||||
<Card class="mb-6" title="📅 今日费用汇总">
|
||||
<div v-if="todayExpenses.length === 0" class="text-center py-8">
|
||||
<div class="text-6xl mb-4">💸</div>
|
||||
<p class="text-gray-500 mb-4">今天还没有费用记录</p>
|
||||
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div class="text-center p-4 bg-red-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">今日支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">¥{{ todayTotal.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">记录笔数</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ todayExpenses.length }}</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-green-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">主要类别</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ topCategory || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日费用列表 -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="expense in todayExpenses" :key="expense.id"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ expense.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
|
||||
<p class="text-sm text-gray-500">{{ expense.time }} · {{ expense.method }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-red-600">¥{{ expense.amount.toLocaleString() }}</p>
|
||||
<Tag size="small" :color="getCategoryColor(expense.category)">{{ expense.category }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用分析 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card title="📊 本周费用趋势">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📈</div>
|
||||
<p class="text-gray-600">费用趋势分析</p>
|
||||
<p class="text-sm text-gray-500">每日费用变化图表</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🏪 商家排行">
|
||||
<div v-if="merchantRanking.length === 0" class="text-center py-8">
|
||||
<div class="text-4xl mb-3">🏪</div>
|
||||
<p class="text-gray-500">暂无商家数据</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(merchant, index) in merchantRanking" :key="merchant.name"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-lg font-bold text-gray-400">{{ index + 1 }}</span>
|
||||
<span class="font-medium">{{ merchant.name }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">¥{{ merchant.total.toLocaleString() }}</p>
|
||||
<p class="text-xs text-gray-500">{{ merchant.count }}次</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 智能分析 -->
|
||||
<Card class="mb-6" title="🧠 智能分析">
|
||||
<div v-if="insights.length === 0" class="text-center py-8">
|
||||
<div class="text-4xl mb-3">🤖</div>
|
||||
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="insight in insights" :key="insight.id" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="text-2xl">{{ insight.emoji }}</span>
|
||||
<div>
|
||||
<h4 class="font-medium mb-1">{{ insight.title }}</h4>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ insight.description }}</p>
|
||||
<Tag :color="insight.type === 'warning' ? 'orange' : insight.type === 'tip' ? 'blue' : 'green'">
|
||||
{{ insight.type === 'warning' ? '注意' : insight.type === 'tip' ? '建议' : '良好' }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 快速添加模态框 -->
|
||||
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
|
||||
<Form :model="quickExpenseForm" layout="vertical">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="金额" required>
|
||||
<InputNumber v-model:value="quickExpenseForm.amount" :precision="2" style="width: 100%" placeholder="0.00" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="支付方式">
|
||||
<Select v-model:value="quickExpenseForm.method">
|
||||
<Select.Option value="cash">现金</Select.Option>
|
||||
<Select.Option value="card">刷卡</Select.Option>
|
||||
<Select.Option value="mobile">手机支付</Select.Option>
|
||||
<Select.Option value="online">网上支付</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="消费类别">
|
||||
<Select v-model:value="quickExpenseForm.category" placeholder="选择或搜索类别" show-search>
|
||||
<Select.Option value="food">餐饮</Select.Option>
|
||||
<Select.Option value="transport">交通</Select.Option>
|
||||
<Select.Option value="shopping">购物</Select.Option>
|
||||
<Select.Option value="entertainment">娱乐</Select.Option>
|
||||
<Select.Option value="medical">医疗</Select.Option>
|
||||
<Select.Option value="education">教育</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="商家名称">
|
||||
<AutoComplete v-model:value="quickExpenseForm.merchant" :options="merchantSuggestions" placeholder="输入商家名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="消费描述">
|
||||
<Input.TextArea v-model:value="quickExpenseForm.description" :rows="2" placeholder="简单描述这笔消费..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="添加标签">
|
||||
<Select v-model:value="quickExpenseForm.tags" mode="tags" placeholder="添加标签便于分类">
|
||||
<Select.Option value="必需品">必需品</Select.Option>
|
||||
<Select.Option value="一次性">一次性</Select.Option>
|
||||
<Select.Option value="定期">定期</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否分期">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Switch v-model:checked="quickExpenseForm.isInstallment" />
|
||||
<span class="text-sm text-gray-500">如果是信用卡分期消费请开启</span>
|
||||
</div>
|
||||
<div v-if="quickExpenseForm.isInstallment" class="mt-3 grid grid-cols-2 gap-4">
|
||||
<Input placeholder="分期期数" />
|
||||
<InputNumber placeholder="每期金额" style="width: 100%" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button @click="showQuickAdd = false">取消</Button>
|
||||
<Space>
|
||||
<Button @click="saveAndContinue">保存并继续</Button>
|
||||
<Button type="primary" @click="saveQuickExpense">保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 相机拍摄模态框 -->
|
||||
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
|
||||
<div class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<video ref="videoRef" autoplay muted style="width: 100%; max-width: 300px; border-radius: 8px;"></video>
|
||||
</div>
|
||||
<canvas ref="canvasRef" style="display: none;"></canvas>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
|
||||
<Button @click="stopCamera">取消</Button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">请将小票置于画面中心</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Card, Button, Table, Tag, Modal, Form, Row, Col, InputNumber,
|
||||
Select, AutoComplete, Input, Switch, Space
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'ExpenseTracking' });
|
||||
@@ -239,7 +27,7 @@ const canvasRef = ref();
|
||||
// 今日费用(空数据)
|
||||
const todayExpenses = ref([]);
|
||||
|
||||
// 商家排行(空数据)
|
||||
// 商家排行(空数据)
|
||||
const merchantRanking = ref([]);
|
||||
|
||||
// 智能分析(空数据)
|
||||
@@ -249,17 +37,20 @@ const insights = ref([]);
|
||||
const merchantSuggestions = ref([]);
|
||||
|
||||
// 计算属性
|
||||
const todayTotal = computed(() =>
|
||||
todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0)
|
||||
const todayTotal = computed(() =>
|
||||
todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0),
|
||||
);
|
||||
|
||||
const topCategory = computed(() => {
|
||||
if (todayExpenses.value.length === 0) return null;
|
||||
const categoryCount = {};
|
||||
todayExpenses.value.forEach(expense => {
|
||||
categoryCount[expense.category] = (categoryCount[expense.category] || 0) + 1;
|
||||
todayExpenses.value.forEach((expense) => {
|
||||
categoryCount[expense.category] =
|
||||
(categoryCount[expense.category] || 0) + 1;
|
||||
});
|
||||
return Object.keys(categoryCount).reduce((a, b) => categoryCount[a] > categoryCount[b] ? a : b);
|
||||
return Object.keys(categoryCount).reduce((a, b) =>
|
||||
categoryCount[a] > categoryCount[b] ? a : b,
|
||||
);
|
||||
});
|
||||
|
||||
// 快速费用表单
|
||||
@@ -270,14 +61,18 @@ const quickExpenseForm = ref({
|
||||
merchant: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
isInstallment: false
|
||||
isInstallment: false,
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colorMap = {
|
||||
'food': 'orange', 'transport': 'blue', 'shopping': 'purple',
|
||||
'entertainment': 'pink', 'medical': 'red', 'education': 'green'
|
||||
food: 'orange',
|
||||
transport: 'blue',
|
||||
shopping: 'purple',
|
||||
entertainment: 'pink',
|
||||
medical: 'red',
|
||||
education: 'green',
|
||||
};
|
||||
return colorMap[category] || 'default';
|
||||
};
|
||||
@@ -297,24 +92,24 @@ const capturePhoto = () => {
|
||||
const canvas = canvasRef.value;
|
||||
const video = videoRef.value;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
context.drawImage(video, 0, 0);
|
||||
|
||||
|
||||
const imageData = canvas.toDataURL('image/jpeg');
|
||||
console.log('拍摄的照片数据:', imageData);
|
||||
|
||||
|
||||
// 这里可以调用OCR API识别小票
|
||||
simulateOcrRecognition(imageData);
|
||||
|
||||
|
||||
stopCamera();
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
const video = videoRef.value;
|
||||
if (video.srcObject) {
|
||||
video.srcObject.getTracks().forEach(track => track.stop());
|
||||
video.srcObject.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
showCamera.value = false;
|
||||
};
|
||||
@@ -355,11 +150,322 @@ const resetQuickForm = () => {
|
||||
merchant: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
isInstallment: false
|
||||
isInstallment: false,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">📱 费用追踪</h1>
|
||||
<p class="text-gray-600">智能费用追踪,支持小票OCR识别和自动分类</p>
|
||||
</div>
|
||||
|
||||
<!-- 快速添加费用 -->
|
||||
<Card class="mb-6" title="⚡ 快速记录">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<!-- 拍照记录 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-blue-400"
|
||||
@click="openCamera"
|
||||
>
|
||||
<div class="mb-3 text-4xl">📷</div>
|
||||
<h3 class="mb-2 font-medium">拍照记录</h3>
|
||||
<p class="text-sm text-gray-500">拍摄小票,自动识别金额和商家</p>
|
||||
</div>
|
||||
|
||||
<!-- 语音记录 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-green-400"
|
||||
@click="startVoiceRecord"
|
||||
>
|
||||
<div class="mb-3 text-4xl">🎤</div>
|
||||
<h3 class="mb-2 font-medium">语音记录</h3>
|
||||
<p class="text-sm text-gray-500">说出消费内容,智能转换为记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-purple-400"
|
||||
@click="showQuickAdd = true"
|
||||
>
|
||||
<div class="mb-3 text-4xl">✍️</div>
|
||||
<h3 class="mb-2 font-medium">手动输入</h3>
|
||||
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 今日费用汇总 -->
|
||||
<Card class="mb-6" title="📅 今日费用汇总">
|
||||
<div v-if="todayExpenses.length === 0" class="py-8 text-center">
|
||||
<div class="mb-4 text-6xl">💸</div>
|
||||
<p class="mb-4 text-gray-500">今天还没有费用记录</p>
|
||||
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="rounded-lg bg-red-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">今日支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
${{ todayTotal.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">记录笔数</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ todayExpenses.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">主要类别</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ topCategory || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日费用列表 -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="expense in todayExpenses"
|
||||
:key="expense.id"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-4"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ expense.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ expense.time }} · {{ expense.method }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-red-600">
|
||||
${{ expense.amount.toLocaleString() }}
|
||||
</p>
|
||||
<Tag size="small" :color="getCategoryColor(expense.category)">
|
||||
{{ expense.category }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用分析 -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="📊 本周费用趋势">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">📈</div>
|
||||
<p class="text-gray-600">费用趋势分析</p>
|
||||
<p class="text-sm text-gray-500">每日费用变化图表</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🏪 商家排行">
|
||||
<div v-if="merchantRanking.length === 0" class="py-8 text-center">
|
||||
<div class="mb-3 text-4xl">🏪</div>
|
||||
<p class="text-gray-500">暂无商家数据</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(merchant, index) in merchantRanking"
|
||||
:key="merchant.name"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-lg font-bold text-gray-400">{{
|
||||
index + 1
|
||||
}}</span>
|
||||
<span class="font-medium">{{ merchant.name }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">
|
||||
${{ merchant.total.toLocaleString() }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">{{ merchant.count }}次</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 智能分析 -->
|
||||
<Card class="mb-6" title="🧠 智能分析">
|
||||
<div v-if="insights.length === 0" class="py-8 text-center">
|
||||
<div class="mb-3 text-4xl">🤖</div>
|
||||
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="insight in insights"
|
||||
:key="insight.id"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="text-2xl">{{ insight.emoji }}</span>
|
||||
<div>
|
||||
<h4 class="mb-1 font-medium">{{ insight.title }}</h4>
|
||||
<p class="mb-2 text-sm text-gray-600">
|
||||
{{ insight.description }}
|
||||
</p>
|
||||
<Tag
|
||||
:color="
|
||||
insight.type === 'warning'
|
||||
? 'orange'
|
||||
: insight.type === 'tip'
|
||||
? 'blue'
|
||||
: 'green'
|
||||
"
|
||||
>
|
||||
{{
|
||||
insight.type === 'warning'
|
||||
? '注意'
|
||||
: insight.type === 'tip'
|
||||
? '建议'
|
||||
: '良好'
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 快速添加模态框 -->
|
||||
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
|
||||
<Form :model="quickExpenseForm" layout="vertical">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="金额" required>
|
||||
<InputNumber
|
||||
v-model:value="quickExpenseForm.amount"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="0.00"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="支付方式">
|
||||
<Select v-model:value="quickExpenseForm.method">
|
||||
<Select.Option value="cash">现金</Select.Option>
|
||||
<Select.Option value="card">刷卡</Select.Option>
|
||||
<Select.Option value="mobile">手机支付</Select.Option>
|
||||
<Select.Option value="online">网上支付</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="消费类别">
|
||||
<Select
|
||||
v-model:value="quickExpenseForm.category"
|
||||
placeholder="选择或搜索类别"
|
||||
show-search
|
||||
>
|
||||
<Select.Option value="food">餐饮</Select.Option>
|
||||
<Select.Option value="transport">交通</Select.Option>
|
||||
<Select.Option value="shopping">购物</Select.Option>
|
||||
<Select.Option value="entertainment">娱乐</Select.Option>
|
||||
<Select.Option value="medical">医疗</Select.Option>
|
||||
<Select.Option value="education">教育</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="商家名称">
|
||||
<AutoComplete
|
||||
v-model:value="quickExpenseForm.merchant"
|
||||
:options="merchantSuggestions"
|
||||
placeholder="输入商家名称"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="消费描述">
|
||||
<Input.TextArea
|
||||
v-model:value="quickExpenseForm.description"
|
||||
:rows="2"
|
||||
placeholder="简单描述这笔消费..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="添加标签">
|
||||
<Select
|
||||
v-model:value="quickExpenseForm.tags"
|
||||
mode="tags"
|
||||
placeholder="添加标签便于分类"
|
||||
>
|
||||
<Select.Option value="必需品">必需品</Select.Option>
|
||||
<Select.Option value="一次性">一次性</Select.Option>
|
||||
<Select.Option value="定期">定期</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否分期">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Switch v-model:checked="quickExpenseForm.isInstallment" />
|
||||
<span class="text-sm text-gray-500"
|
||||
>如果是信用卡分期消费请开启</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="quickExpenseForm.isInstallment"
|
||||
class="mt-3 grid grid-cols-2 gap-4"
|
||||
>
|
||||
<Input placeholder="分期期数" />
|
||||
<InputNumber placeholder="每期金额" style="width: 100%" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button @click="showQuickAdd = false">取消</Button>
|
||||
<Space>
|
||||
<Button @click="saveAndContinue">保存并继续</Button>
|
||||
<Button type="primary" @click="saveQuickExpense">保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 相机拍摄模态框 -->
|
||||
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
|
||||
<div class="py-8 text-center">
|
||||
<div class="mb-4">
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
muted
|
||||
style="width: 100%; max-width: 300px; border-radius: 8px"
|
||||
></video>
|
||||
</div>
|
||||
<canvas ref="canvasRef" style="display: none"></canvas>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
|
||||
<Button @click="stopCamera">取消</Button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">请将小票置于画面中心</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
RangePicker,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'InvoiceManagement' });
|
||||
|
||||
const showOcrUpload = ref(false);
|
||||
const showCreateInvoice = ref(false);
|
||||
const ocrResult = ref(null);
|
||||
|
||||
// 发票统计(无虚拟数据)
|
||||
const invoiceStats = ref({
|
||||
pending: 0,
|
||||
issued: 0,
|
||||
received: 0,
|
||||
totalAmount: 0,
|
||||
});
|
||||
|
||||
// 发票列表(空数据)
|
||||
const invoices = ref([]);
|
||||
|
||||
// 发票表格列
|
||||
const invoiceColumns = [
|
||||
{
|
||||
title: '发票号码',
|
||||
dataIndex: 'invoiceNumber',
|
||||
key: 'invoiceNumber',
|
||||
width: 150,
|
||||
},
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
|
||||
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
];
|
||||
|
||||
// 发票明细表格列
|
||||
const invoiceItemColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
|
||||
// 客户选项(空数据)
|
||||
const customerOptions = ref([]);
|
||||
|
||||
// 发票表单
|
||||
const invoiceForm = ref({
|
||||
type: 'sales',
|
||||
code: '',
|
||||
customer: '',
|
||||
issueDate: dayjs(),
|
||||
taxRate: 13,
|
||||
items: [],
|
||||
notes: '',
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getInvoiceStatusColor = (status: string) => {
|
||||
const statusMap = { pending: 'orange', issued: 'green', cancelled: 'red' };
|
||||
return statusMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getInvoiceStatusText = (status: string) => {
|
||||
const textMap = { pending: '待开具', issued: '已开具', cancelled: '已作废' };
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return invoiceForm.value.items.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return calculateTotal() * (invoiceForm.value.taxRate / 100);
|
||||
};
|
||||
|
||||
const handleOcrUpload = (info) => {
|
||||
console.log('OCR上传处理:', info);
|
||||
// 模拟OCR识别结果
|
||||
setTimeout(() => {
|
||||
ocrResult.value = {
|
||||
invoiceNumber: `INV${Date.now()}`,
|
||||
issueDate: dayjs().format('YYYY-MM-DD'),
|
||||
seller: '示例公司',
|
||||
buyer: '客户公司',
|
||||
amount: '1000.00',
|
||||
tax: '130.00',
|
||||
};
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const saveOcrInvoice = () => {
|
||||
console.log('保存OCR识别的发票:', ocrResult.value);
|
||||
showOcrUpload.value = false;
|
||||
ocrResult.value = null;
|
||||
};
|
||||
|
||||
const addInvoiceItem = () => {
|
||||
invoiceForm.value.items.push({
|
||||
name: '',
|
||||
specification: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
amount: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const batchImport = () => {
|
||||
console.log('批量导入发票');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">📄 发票管理</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">📄 发票管理</h1>
|
||||
<p class="text-gray-600">管理进项发票、销项发票,支持OCR识别和自动记账</p>
|
||||
</div>
|
||||
|
||||
<!-- 发票统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📤</div>
|
||||
<p class="text-sm text-gray-500">待开发票</p>
|
||||
<p class="text-2xl font-bold text-orange-600">{{ invoiceStats.pending }}</p>
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
{{ invoiceStats.pending }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">✅</div>
|
||||
<p class="text-sm text-gray-500">已开发票</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ invoiceStats.issued }}</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ invoiceStats.issued }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📥</div>
|
||||
<p class="text-sm text-gray-500">收到发票</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ invoiceStats.received }}</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ invoiceStats.received }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">发票金额</p>
|
||||
<p class="text-2xl font-bold text-purple-600">¥{{ invoiceStats.totalAmount.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
${{ invoiceStats.totalAmount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -59,22 +204,18 @@
|
||||
<Button type="primary" @click="showCreateInvoice = true">
|
||||
📝 开具发票
|
||||
</Button>
|
||||
<Button @click="showOcrUpload = true">
|
||||
📷 OCR识别
|
||||
</Button>
|
||||
<Button @click="batchImport">
|
||||
📥 批量导入
|
||||
</Button>
|
||||
<Button @click="showOcrUpload = true"> 📷 OCR识别 </Button>
|
||||
<Button @click="batchImport"> 📥 批量导入 </Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 发票列表 -->
|
||||
<Card title="📋 发票清单">
|
||||
<div v-if="invoices.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">📄</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无发票记录</h3>
|
||||
<p class="text-gray-500 mb-6">开始管理您的发票,支持OCR自动识别</p>
|
||||
<div v-if="invoices.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">📄</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无发票记录</h3>
|
||||
<p class="mb-6 text-gray-500">开始管理您的发票,支持OCR自动识别</p>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" size="large" @click="showCreateInvoice = true">
|
||||
📝 开具发票
|
||||
@@ -84,11 +225,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table v-else :columns="invoiceColumns" :dataSource="invoices" :pagination="{ pageSize: 10 }">
|
||||
<Table
|
||||
v-else
|
||||
:columns="invoiceColumns"
|
||||
:data-source="invoices"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold text-blue-600">
|
||||
¥{{ record.amount.toLocaleString() }}
|
||||
${{ record.amount.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
@@ -110,32 +256,46 @@
|
||||
|
||||
<!-- OCR上传模态框 -->
|
||||
<Modal v-model:open="showOcrUpload" title="📷 OCR发票识别" width="600px">
|
||||
<div class="text-center py-8">
|
||||
<div class="py-8 text-center">
|
||||
<Upload
|
||||
:customRequest="handleOcrUpload"
|
||||
:custom-request="handleOcrUpload"
|
||||
accept="image/*,.pdf"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
:multiple="false"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="text-6xl mb-4">📷</div>
|
||||
<p class="text-lg font-medium mb-2">上传发票图片或PDF</p>
|
||||
<div class="mb-4 text-6xl">📷</div>
|
||||
<p class="mb-2 text-lg font-medium">上传发票图片或PDF</p>
|
||||
<p class="text-sm text-gray-500">支持自动OCR识别发票信息</p>
|
||||
<p class="text-xs text-gray-400 mt-2">支持格式: JPG, PNG, PDF</p>
|
||||
<p class="mt-2 text-xs text-gray-400">支持格式: JPG, PNG, PDF</p>
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<div v-if="ocrResult" class="mt-6 p-4 bg-green-50 rounded-lg">
|
||||
<h4 class="font-medium text-green-800 mb-3">🎉 识别成功</h4>
|
||||
|
||||
<div v-if="ocrResult" class="mt-6 rounded-lg bg-green-50 p-4">
|
||||
<h4 class="mb-3 font-medium text-green-800">🎉 识别成功</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span class="text-gray-600">发票号码:</span> {{ ocrResult.invoiceNumber }}</div>
|
||||
<div><span class="text-gray-600">开票日期:</span> {{ ocrResult.issueDate }}</div>
|
||||
<div><span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}</div>
|
||||
<div><span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}</div>
|
||||
<div><span class="text-gray-600">金额:</span> ¥{{ ocrResult.amount }}</div>
|
||||
<div><span class="text-gray-600">税额:</span> ¥{{ ocrResult.tax }}</div>
|
||||
<div>
|
||||
<span class="text-gray-600">发票号码:</span>
|
||||
{{ ocrResult.invoiceNumber }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">开票日期:</span>
|
||||
{{ ocrResult.issueDate }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">金额:</span> ${{ ocrResult.amount }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">税额:</span> ${{ ocrResult.tax }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Button type="primary" @click="saveOcrInvoice">保存到系统</Button>
|
||||
@@ -175,14 +335,22 @@
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="开票日期" required>
|
||||
<DatePicker v-model:value="invoiceForm.issueDate" style="width: 100%" />
|
||||
<DatePicker
|
||||
v-model:value="invoiceForm.issueDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 发票项目明细 -->
|
||||
<Form.Item label="发票明细">
|
||||
<Table :columns="invoiceItemColumns" :dataSource="invoiceForm.items" :pagination="false" size="small">
|
||||
<Table
|
||||
:columns="invoiceItemColumns"
|
||||
:data-source="invoiceForm.items"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #footer>
|
||||
<Button type="dashed" block @click="addInvoiceItem">
|
||||
➕ 添加明细项
|
||||
@@ -206,139 +374,33 @@
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="金额合计">
|
||||
<Input :value="`¥${calculateTotal().toLocaleString()}`" disabled />
|
||||
<Input
|
||||
:value="`$${calculateTotal().toLocaleString()}`"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="税额">
|
||||
<Input :value="`¥${calculateTax().toLocaleString()}`" disabled />
|
||||
<Input :value="`$${calculateTax().toLocaleString()}`" disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="备注">
|
||||
<Input.TextArea v-model:value="invoiceForm.notes" :rows="3" placeholder="发票备注信息..." />
|
||||
<Input.TextArea
|
||||
v-model:value="invoiceForm.notes"
|
||||
:rows="3"
|
||||
placeholder="发票备注信息..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
Card, Input, Select, RangePicker, Button, Table, Tag, Space, Modal,
|
||||
Upload, Form, Row, Col, DatePicker, AutoComplete
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'InvoiceManagement' });
|
||||
|
||||
const showOcrUpload = ref(false);
|
||||
const showCreateInvoice = ref(false);
|
||||
const ocrResult = ref(null);
|
||||
|
||||
// 发票统计(无虚拟数据)
|
||||
const invoiceStats = ref({
|
||||
pending: 0,
|
||||
issued: 0,
|
||||
received: 0,
|
||||
totalAmount: 0
|
||||
});
|
||||
|
||||
// 发票列表(空数据)
|
||||
const invoices = ref([]);
|
||||
|
||||
// 发票表格列
|
||||
const invoiceColumns = [
|
||||
{ title: '发票号码', dataIndex: 'invoiceNumber', key: 'invoiceNumber', width: 150 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
|
||||
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 200 }
|
||||
];
|
||||
|
||||
// 发票明细表格列
|
||||
const invoiceItemColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 80 }
|
||||
];
|
||||
|
||||
// 客户选项(空数据)
|
||||
const customerOptions = ref([]);
|
||||
|
||||
// 发票表单
|
||||
const invoiceForm = ref({
|
||||
type: 'sales',
|
||||
code: '',
|
||||
customer: '',
|
||||
issueDate: dayjs(),
|
||||
taxRate: 13,
|
||||
items: [],
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getInvoiceStatusColor = (status: string) => {
|
||||
const statusMap = { 'pending': 'orange', 'issued': 'green', 'cancelled': 'red' };
|
||||
return statusMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getInvoiceStatusText = (status: string) => {
|
||||
const textMap = { 'pending': '待开具', 'issued': '已开具', 'cancelled': '已作废' };
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return invoiceForm.value.items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return calculateTotal() * (invoiceForm.value.taxRate / 100);
|
||||
};
|
||||
|
||||
const handleOcrUpload = (info) => {
|
||||
console.log('OCR上传处理:', info);
|
||||
// 模拟OCR识别结果
|
||||
setTimeout(() => {
|
||||
ocrResult.value = {
|
||||
invoiceNumber: 'INV' + Date.now(),
|
||||
issueDate: dayjs().format('YYYY-MM-DD'),
|
||||
seller: '示例公司',
|
||||
buyer: '客户公司',
|
||||
amount: '1000.00',
|
||||
tax: '130.00'
|
||||
};
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const saveOcrInvoice = () => {
|
||||
console.log('保存OCR识别的发票:', ocrResult.value);
|
||||
showOcrUpload.value = false;
|
||||
ocrResult.value = null;
|
||||
};
|
||||
|
||||
const addInvoiceItem = () => {
|
||||
invoiceForm.value.items.push({
|
||||
name: '',
|
||||
specification: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
amount: 0
|
||||
});
|
||||
};
|
||||
|
||||
const batchImport = () => {
|
||||
console.log('批量导入发票');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
238
apps/web-antd/src/views/finance/media/index.vue
Normal file
238
apps/web-antd/src/views/finance/media/index.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Card, Space, Table, Tag, Select, Tooltip, message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
defineOptions({ name: 'FinanceMediaCenter' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
|
||||
const selectedTypes = ref<string[]>([]);
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '图片', value: 'photo' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
{ label: '语音', value: 'voice' },
|
||||
{ label: '文件', value: 'document' },
|
||||
{ label: '视频消息', value: 'video_note' },
|
||||
{ label: '动图', value: 'animation' },
|
||||
{ label: '贴纸', value: 'sticker' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '类型', dataIndex: 'fileType', key: 'fileType', width: 120 },
|
||||
{ title: '说明', dataIndex: 'caption', key: 'caption', ellipsis: true },
|
||||
{ title: '发送者', dataIndex: 'displayName', key: 'sender', width: 160 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 160 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 200 },
|
||||
{ title: '大小', dataIndex: 'fileSize', key: 'fileSize', width: 120 },
|
||||
{ title: '状态', dataIndex: 'available', key: 'available', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' },
|
||||
];
|
||||
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total: number) => `共 ${total} 条媒体记录`,
|
||||
});
|
||||
|
||||
const loading = computed(
|
||||
() => financeStore.loading.mediaMessages || isRefreshing.value,
|
||||
);
|
||||
|
||||
const mediaMessages = computed(() => {
|
||||
const types = selectedTypes.value;
|
||||
if (!types || types.length === 0) {
|
||||
return financeStore.mediaMessages;
|
||||
}
|
||||
return financeStore.mediaMessages.filter((item) =>
|
||||
types.includes(item.fileType),
|
||||
);
|
||||
});
|
||||
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
photo: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
voice: '语音',
|
||||
document: '文件',
|
||||
video_note: '视频消息',
|
||||
animation: '动图',
|
||||
sticker: '贴纸',
|
||||
};
|
||||
|
||||
function formatFileSize(size?: number) {
|
||||
if (!size || size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function fetchMedia() {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await financeStore.fetchMediaMessages({
|
||||
limit: DEFAULT_LIMIT,
|
||||
fileTypes: selectedTypes.value.length > 0 ? selectedTypes.value : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[finance][media] fetch failed', error);
|
||||
message.error('媒体记录加载失败,请稍后重试');
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(record: (typeof mediaMessages.value)[number]) {
|
||||
if (!record.available || !record.downloadUrl) {
|
||||
message.warning('文件不可用或已被移除');
|
||||
return;
|
||||
}
|
||||
const apiBase = (import.meta.env.VITE_GLOB_API_URL ?? '').replace(/\/$/, '');
|
||||
const url = record.downloadUrl.startsWith('http')
|
||||
? record.downloadUrl
|
||||
: `${apiBase}${record.downloadUrl.startsWith('/') ? '' : '/'}${record.downloadUrl}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMedia();
|
||||
});
|
||||
|
||||
watch(selectedTypes, () => {
|
||||
// 每次筛选更新时刷新数据
|
||||
fetchMedia();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Card
|
||||
:loading="loading"
|
||||
title="📂 多媒体中心"
|
||||
bordered
|
||||
>
|
||||
<template #extra>
|
||||
<Space :size="12">
|
||||
<Select
|
||||
v-model:value="selectedTypes"
|
||||
mode="multiple"
|
||||
:options="typeOptions"
|
||||
allow-clear
|
||||
placeholder="筛选媒体类型"
|
||||
style="min-width: 240px"
|
||||
max-tag-count="responsive"
|
||||
/>
|
||||
<Button type="primary" :loading="isRefreshing" @click="fetchMedia">
|
||||
手动刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="mediaMessages"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 1000 }"
|
||||
row-key="id"
|
||||
bordered
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fileType'">
|
||||
<Tag color="blue">
|
||||
{{ typeLabelMap[record.fileType] ?? record.fileType }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'caption'">
|
||||
<Tooltip>
|
||||
<template #title>
|
||||
<div class="max-w-72 whitespace-pre-wrap">
|
||||
{{ record.caption || '(无备注)' }}
|
||||
</div>
|
||||
</template>
|
||||
<span class="block max-w-56 truncate">
|
||||
{{ record.caption || '(无备注)' }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'sender'">
|
||||
{{ record.displayName || `用户 ${record.userId}` }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'fileSize'">
|
||||
{{ formatFileSize(record.fileSize) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'available'">
|
||||
<Tag :color="record.available ? 'success' : 'error'">
|
||||
{{ record.available ? '可下载' : '已缺失' }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!record.available"
|
||||
@click="handleDownload(record)"
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Tooltip title="复制文件路径">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="
|
||||
navigator.clipboard
|
||||
.writeText(record.filePath)
|
||||
.then(() => message.success('已复制文件路径'))
|
||||
.catch(() => message.error('复制失败'))
|
||||
"
|
||||
>
|
||||
复制路径
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ record[column.dataIndex as keyof typeof record] ?? '-' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Steps,
|
||||
Tag,
|
||||
Timeline,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinancialPlanning' });
|
||||
|
||||
const currentStep = ref(0);
|
||||
const planningResult = ref(null);
|
||||
|
||||
// 规划数据
|
||||
const planningData = ref({
|
||||
monthlyIncome: null,
|
||||
monthlyExpense: null,
|
||||
cashAssets: null,
|
||||
investmentAssets: null,
|
||||
totalDebt: null,
|
||||
goals: [],
|
||||
riskAnswers: [],
|
||||
});
|
||||
|
||||
// 风险评估问题
|
||||
const riskQuestions = ref([
|
||||
{
|
||||
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
|
||||
options: [
|
||||
'立即卖出,避免更大损失',
|
||||
'保持观望,等待市场恢复',
|
||||
'继续持有,甚至考虑加仓',
|
||||
'完全不担心,长期投资',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '您更偏好哪种投资方式?',
|
||||
options: [
|
||||
'银行定期存款,安全稳定',
|
||||
'货币基金,流动性好',
|
||||
'混合型基金,平衡风险收益',
|
||||
'股票投资,追求高回报',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '您的投资经验如何?',
|
||||
options: [
|
||||
'完全没有经验',
|
||||
'了解基本概念',
|
||||
'有一定实践经验',
|
||||
'经验丰富,熟悉各种产品',
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 资产配置建议(空数据,根据评估生成)
|
||||
const assetAllocation = ref([]);
|
||||
|
||||
// 执行计划(空数据)
|
||||
const executionPlan = ref([]);
|
||||
|
||||
// 方法实现
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const addGoal = () => {
|
||||
planningData.value.goals.push({
|
||||
name: '',
|
||||
amount: null,
|
||||
deadline: null,
|
||||
priority: 'medium',
|
||||
type: 'other',
|
||||
});
|
||||
};
|
||||
|
||||
const removeGoal = (index: number) => {
|
||||
planningData.value.goals.splice(index, 1);
|
||||
};
|
||||
|
||||
const generatePlan = () => {
|
||||
console.log('生成规划方案:', planningData.value);
|
||||
// 这里实现规划算法
|
||||
setTimeout(() => {
|
||||
planningResult.value = {
|
||||
riskLevel: 'moderate',
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
// 根据风险评估生成资产配置
|
||||
assetAllocation.value = [
|
||||
{
|
||||
type: 'cash',
|
||||
name: '现金类',
|
||||
percentage: 20,
|
||||
color: 'text-blue-600',
|
||||
description: '货币基金',
|
||||
},
|
||||
{
|
||||
type: 'bond',
|
||||
name: '债券类',
|
||||
percentage: 30,
|
||||
color: 'text-green-600',
|
||||
description: '债券基金',
|
||||
},
|
||||
{
|
||||
type: 'stock',
|
||||
name: '股票类',
|
||||
percentage: 40,
|
||||
color: 'text-red-600',
|
||||
description: '股票基金',
|
||||
},
|
||||
{
|
||||
type: 'alternative',
|
||||
name: '另类投资',
|
||||
percentage: 10,
|
||||
color: 'text-purple-600',
|
||||
description: 'REITs等',
|
||||
},
|
||||
];
|
||||
|
||||
// 生成执行计划
|
||||
executionPlan.value = [
|
||||
{
|
||||
title: '建立紧急基金',
|
||||
description: '准备3-6个月的生活费作为紧急基金',
|
||||
timeline: '1-2个月',
|
||||
color: 'red',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
title: '开设投资账户',
|
||||
description: '选择合适的券商开设证券账户',
|
||||
timeline: '第3个月',
|
||||
color: 'blue',
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
title: '开始定投计划',
|
||||
description: '按照资产配置比例开始定期投资',
|
||||
timeline: '第4个月开始',
|
||||
color: 'green',
|
||||
priority: 'normal',
|
||||
},
|
||||
];
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getRiskEmoji = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '🛡️';
|
||||
if (score <= 6) return '⚖️';
|
||||
return '🚀';
|
||||
};
|
||||
|
||||
const getRiskLevel = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '保守型投资者';
|
||||
if (score <= 6) return '平衡型投资者';
|
||||
return '积极型投资者';
|
||||
};
|
||||
|
||||
const getRiskDescription = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '偏好稳健投资,注重本金安全';
|
||||
if (score <= 6) return '平衡风险与收益,适度投资';
|
||||
return '愿意承担较高风险,追求高收益';
|
||||
};
|
||||
|
||||
const savePlan = () => {
|
||||
console.log('保存财务规划:', planningData.value, planningResult.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎯 财务规划</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🎯 财务规划</h1>
|
||||
<p class="text-gray-600">智能财务规划向导,帮您制定个性化理财计划</p>
|
||||
</div>
|
||||
|
||||
@@ -16,35 +217,57 @@
|
||||
|
||||
<!-- 步骤1: 基本信息 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h3 class="text-lg font-medium mb-4">💼 收入支出信息</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">💼 收入支出信息</h3>
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="月平均收入">
|
||||
<InputNumber v-model:value="planningData.monthlyIncome" :precision="0" style="width: 100%" placeholder="请输入月收入" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.monthlyIncome"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入月收入"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="月平均支出">
|
||||
<InputNumber v-model:value="planningData.monthlyExpense" :precision="0" style="width: 100%" placeholder="请输入月支出" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.monthlyExpense"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入月支出"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<h3 class="text-lg font-medium mb-4 mt-6">💰 资产负债情况</h3>
|
||||
<h3 class="mb-4 mt-6 text-lg font-medium">💰 资产负债情况</h3>
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Form.Item label="现金及存款">
|
||||
<InputNumber v-model:value="planningData.cashAssets" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.cashAssets"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="投资资产">
|
||||
<InputNumber v-model:value="planningData.investmentAssets" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.investmentAssets"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="负债总额">
|
||||
<InputNumber v-model:value="planningData.totalDebt" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.totalDebt"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -52,9 +275,13 @@
|
||||
|
||||
<!-- 步骤2: 目标设定 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h3 class="text-lg font-medium mb-4">🎯 理财目标设置</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">🎯 理财目标设置</h3>
|
||||
<div class="space-y-6">
|
||||
<div v-for="(goal, index) in planningData.goals" :key="index" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
v-for="(goal, index) in planningData.goals"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Form.Item label="目标名称">
|
||||
@@ -63,17 +290,26 @@
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="目标金额">
|
||||
<InputNumber v-model:value="goal.amount" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="goal.amount"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Form.Item label="目标期限">
|
||||
<DatePicker v-model:value="goal.deadline" style="width: 100%" />
|
||||
<DatePicker
|
||||
v-model:value="goal.deadline"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="2">
|
||||
<Form.Item label=" ">
|
||||
<Button type="text" danger @click="removeGoal(index)">🗑️</Button>
|
||||
<Button type="text" danger @click="removeGoal(index)">
|
||||
🗑️
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -109,13 +345,20 @@
|
||||
|
||||
<!-- 步骤3: 风险评估 -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h3 class="text-lg font-medium mb-4">⚖️ 投资风险评估</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">⚖️ 投资风险评估</h3>
|
||||
<div class="space-y-6">
|
||||
<div v-for="(question, index) in riskQuestions" :key="index" class="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-medium mb-3">{{ question.title }}</h4>
|
||||
<div
|
||||
v-for="(question, index) in riskQuestions"
|
||||
:key="index"
|
||||
class="rounded-lg bg-gray-50 p-4"
|
||||
>
|
||||
<h4 class="mb-3 font-medium">{{ question.title }}</h4>
|
||||
<Radio.Group v-model:value="planningData.riskAnswers[index]">
|
||||
<div class="space-y-2">
|
||||
<div v-for="(option, optIndex) in question.options" :key="optIndex">
|
||||
<div
|
||||
v-for="(option, optIndex) in question.options"
|
||||
:key="optIndex"
|
||||
>
|
||||
<Radio :value="optIndex">{{ option }}</Radio>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,15 +369,17 @@
|
||||
|
||||
<!-- 步骤4: 规划方案 -->
|
||||
<div v-if="currentStep === 3">
|
||||
<div v-if="!planningResult" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🤖</div>
|
||||
<p class="text-gray-500 mb-6">正在为您生成个性化财务规划方案...</p>
|
||||
<Button type="primary" @click="generatePlan" loading>生成规划方案</Button>
|
||||
<div v-if="!planningResult" class="py-12 text-center">
|
||||
<div class="mb-4 text-6xl">🤖</div>
|
||||
<p class="mb-6 text-gray-500">正在为您生成个性化财务规划方案...</p>
|
||||
<Button type="primary" @click="generatePlan" loading>
|
||||
生成规划方案
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<h3 class="text-lg font-medium mb-4">📋 您的专属财务规划方案</h3>
|
||||
|
||||
<h3 class="mb-4 text-lg font-medium">📋 您的专属财务规划方案</h3>
|
||||
|
||||
<!-- 风险评估结果 -->
|
||||
<Card class="mb-4" title="风险偏好分析">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -148,11 +393,19 @@
|
||||
|
||||
<!-- 资产配置建议 -->
|
||||
<Card class="mb-4" title="资产配置建议">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="allocation in assetAllocation" :key="allocation.type" class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div
|
||||
v-for="allocation in assetAllocation"
|
||||
:key="allocation.type"
|
||||
class="rounded-lg bg-gray-50 p-4 text-center"
|
||||
>
|
||||
<p class="text-sm text-gray-500">{{ allocation.name }}</p>
|
||||
<p class="text-xl font-bold" :class="allocation.color">{{ allocation.percentage }}%</p>
|
||||
<p class="text-xs text-gray-400">{{ allocation.description }}</p>
|
||||
<p class="text-xl font-bold" :class="allocation.color">
|
||||
{{ allocation.percentage }}%
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ allocation.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -160,15 +413,24 @@
|
||||
<!-- 具体执行计划 -->
|
||||
<Card title="执行计划">
|
||||
<Timeline>
|
||||
<Timeline.Item v-for="(step, index) in executionPlan" :key="index" :color="step.color">
|
||||
<Timeline.Item
|
||||
v-for="(step, index) in executionPlan"
|
||||
:key="index"
|
||||
:color="step.color"
|
||||
>
|
||||
<div class="mb-2">
|
||||
<span class="font-medium">{{ step.title }}</span>
|
||||
<Tag class="ml-2" :color="step.priority === 'high' ? 'red' : 'blue'">
|
||||
<Tag
|
||||
class="ml-2"
|
||||
:color="step.priority === 'high' ? 'red' : 'blue'"
|
||||
>
|
||||
{{ step.priority === 'high' ? '高优先级' : '普通' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{{ step.description }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">预期完成时间: {{ step.timeline }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
预期完成时间: {{ step.timeline }}
|
||||
</p>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Card>
|
||||
@@ -176,174 +438,20 @@
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮 -->
|
||||
<div class="flex justify-between mt-8">
|
||||
<div class="mt-8 flex justify-between">
|
||||
<Button v-if="currentStep > 0" @click="prevStep">上一步</Button>
|
||||
<div v-else></div>
|
||||
<Button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</Button>
|
||||
<Button v-if="currentStep < 3" type="primary" @click="nextStep">
|
||||
下一步
|
||||
</Button>
|
||||
<Button v-else type="primary" @click="savePlan">保存规划</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
Card, Steps, Row, Col, Form, InputNumber, Button, Select,
|
||||
DatePicker, Radio, Timeline, Tag
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'FinancialPlanning' });
|
||||
|
||||
const currentStep = ref(0);
|
||||
const planningResult = ref(null);
|
||||
|
||||
// 规划数据
|
||||
const planningData = ref({
|
||||
monthlyIncome: null,
|
||||
monthlyExpense: null,
|
||||
cashAssets: null,
|
||||
investmentAssets: null,
|
||||
totalDebt: null,
|
||||
goals: [],
|
||||
riskAnswers: []
|
||||
});
|
||||
|
||||
// 风险评估问题
|
||||
const riskQuestions = ref([
|
||||
{
|
||||
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
|
||||
options: [
|
||||
'立即卖出,避免更大损失',
|
||||
'保持观望,等待市场恢复',
|
||||
'继续持有,甚至考虑加仓',
|
||||
'完全不担心,长期投资'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '您更偏好哪种投资方式?',
|
||||
options: [
|
||||
'银行定期存款,安全稳定',
|
||||
'货币基金,流动性好',
|
||||
'混合型基金,平衡风险收益',
|
||||
'股票投资,追求高回报'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '您的投资经验如何?',
|
||||
options: [
|
||||
'完全没有经验',
|
||||
'了解基本概念',
|
||||
'有一定实践经验',
|
||||
'经验丰富,熟悉各种产品'
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
// 资产配置建议(空数据,根据评估生成)
|
||||
const assetAllocation = ref([]);
|
||||
|
||||
// 执行计划(空数据)
|
||||
const executionPlan = ref([]);
|
||||
|
||||
// 方法实现
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const addGoal = () => {
|
||||
planningData.value.goals.push({
|
||||
name: '',
|
||||
amount: null,
|
||||
deadline: null,
|
||||
priority: 'medium',
|
||||
type: 'other'
|
||||
});
|
||||
};
|
||||
|
||||
const removeGoal = (index: number) => {
|
||||
planningData.value.goals.splice(index, 1);
|
||||
};
|
||||
|
||||
const generatePlan = () => {
|
||||
console.log('生成规划方案:', planningData.value);
|
||||
// 这里实现规划算法
|
||||
setTimeout(() => {
|
||||
planningResult.value = {
|
||||
riskLevel: 'moderate',
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// 根据风险评估生成资产配置
|
||||
assetAllocation.value = [
|
||||
{ type: 'cash', name: '现金类', percentage: 20, color: 'text-blue-600', description: '货币基金' },
|
||||
{ type: 'bond', name: '债券类', percentage: 30, color: 'text-green-600', description: '债券基金' },
|
||||
{ type: 'stock', name: '股票类', percentage: 40, color: 'text-red-600', description: '股票基金' },
|
||||
{ type: 'alternative', name: '另类投资', percentage: 10, color: 'text-purple-600', description: 'REITs等' }
|
||||
];
|
||||
|
||||
// 生成执行计划
|
||||
executionPlan.value = [
|
||||
{
|
||||
title: '建立紧急基金',
|
||||
description: '准备3-6个月的生活费作为紧急基金',
|
||||
timeline: '1-2个月',
|
||||
color: 'red',
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
title: '开设投资账户',
|
||||
description: '选择合适的券商开设证券账户',
|
||||
timeline: '第3个月',
|
||||
color: 'blue',
|
||||
priority: 'normal'
|
||||
},
|
||||
{
|
||||
title: '开始定投计划',
|
||||
description: '按照资产配置比例开始定期投资',
|
||||
timeline: '第4个月开始',
|
||||
color: 'green',
|
||||
priority: 'normal'
|
||||
}
|
||||
];
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getRiskEmoji = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '🛡️';
|
||||
if (score <= 6) return '⚖️';
|
||||
return '🚀';
|
||||
};
|
||||
|
||||
const getRiskLevel = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '保守型投资者';
|
||||
if (score <= 6) return '平衡型投资者';
|
||||
return '积极型投资者';
|
||||
};
|
||||
|
||||
const getRiskDescription = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '偏好稳健投资,注重本金安全';
|
||||
if (score <= 6) return '平衡风险与收益,适度投资';
|
||||
return '愿意承担较高风险,追求高收益';
|
||||
};
|
||||
|
||||
const savePlan = () => {
|
||||
console.log('保存财务规划:', planningData.value, planningResult.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +1,7 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">💼 投资组合</h1>
|
||||
<p class="text-gray-600">实时跟踪投资组合表现,智能分析投资收益</p>
|
||||
</div>
|
||||
|
||||
<!-- 组合概览 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">总市值</p>
|
||||
<p class="text-2xl font-bold text-blue-600">¥{{ portfolioStats.totalValue.toLocaleString() }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收益</p>
|
||||
<p class="text-2xl font-bold" :class="portfolioStats.totalProfit >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}¥{{ portfolioStats.totalProfit.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">⚡</div>
|
||||
<p class="text-sm text-gray-500">收益率</p>
|
||||
<p class="text-2xl font-bold" :class="portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ portfolioStats.returnRate >= 0 ? '+' : '' }}{{ portfolioStats.returnRate.toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🎯</div>
|
||||
<p class="text-sm text-gray-500">持仓数量</p>
|
||||
<p class="text-2xl font-bold text-purple-600">{{ holdings.length }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 持仓列表 -->
|
||||
<Card title="📋 持仓明细" class="mb-6">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="showAddHolding = true">➕ 添加持仓</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="holdings.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">💼</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无投资持仓</h3>
|
||||
<p class="text-gray-500 mb-6">开始记录您的投资组合</p>
|
||||
<Button type="primary" size="large" @click="showAddHolding = true">
|
||||
➕ 添加第一笔投资
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table v-else :columns="holdingColumns" :dataSource="holdings" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'profit'">
|
||||
<span :class="record.profit >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ record.profit >= 0 ? '+' : '' }}¥{{ record.profit.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'returnRate'">
|
||||
<span :class="record.returnRate >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ record.returnRate >= 0 ? '+' : '' }}{{ record.returnRate.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 投资分析 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="📈 收益走势">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📊</div>
|
||||
<p class="text-gray-600">投资收益趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🥧 资产配置">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">🍰</div>
|
||||
<p class="text-gray-600">资产配置分布图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Card, Button, Table } from 'ant-design-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Table } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'InvestmentPortfolio' });
|
||||
|
||||
@@ -107,7 +11,7 @@ const showAddHolding = ref(false);
|
||||
const portfolioStats = ref({
|
||||
totalValue: 0,
|
||||
totalProfit: 0,
|
||||
returnRate: 0
|
||||
returnRate: 0,
|
||||
});
|
||||
|
||||
// 持仓列表(空数据)
|
||||
@@ -120,10 +24,154 @@ const holdingColumns = [
|
||||
{ title: '成本价', dataIndex: 'costPrice', key: 'costPrice', width: 100 },
|
||||
{ title: '现价', dataIndex: 'currentPrice', key: 'currentPrice', width: 100 },
|
||||
{ title: '盈亏', dataIndex: 'profit', key: 'profit', width: 120 },
|
||||
{ title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 }
|
||||
{ title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">💼 投资组合</h1>
|
||||
<p class="text-gray-600">实时跟踪投资组合表现,智能分析投资收益</p>
|
||||
</div>
|
||||
|
||||
<!-- 组合概览 -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">总市值</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
${{ portfolioStats.totalValue.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收益</p>
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
:class="
|
||||
portfolioStats.totalProfit >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}${{
|
||||
portfolioStats.totalProfit.toLocaleString()
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">⚡</div>
|
||||
<p class="text-sm text-gray-500">收益率</p>
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
:class="
|
||||
portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ portfolioStats.returnRate >= 0 ? '+' : ''
|
||||
}}{{ portfolioStats.returnRate.toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🎯</div>
|
||||
<p class="text-sm text-gray-500">持仓数量</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
{{ holdings.length }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 持仓列表 -->
|
||||
<Card title="📋 持仓明细" class="mb-6">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="showAddHolding = true">
|
||||
➕ 添加持仓
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="holdings.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">💼</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无投资持仓</h3>
|
||||
<p class="mb-6 text-gray-500">开始记录您的投资组合</p>
|
||||
<Button type="primary" size="large" @click="showAddHolding = true">
|
||||
➕ 添加第一笔投资
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
v-else
|
||||
:columns="holdingColumns"
|
||||
:data-source="holdings"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'profit'">
|
||||
<span
|
||||
:class="
|
||||
record.profit >= 0
|
||||
? 'font-semibold text-green-600'
|
||||
: 'font-semibold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.profit >= 0 ? '+' : '' }}${{
|
||||
record.profit.toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'returnRate'">
|
||||
<span
|
||||
:class="
|
||||
record.returnRate >= 0
|
||||
? 'font-semibold text-green-600'
|
||||
: 'font-semibold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.returnRate >= 0 ? '+' : ''
|
||||
}}{{ record.returnRate.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 投资分析 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="📈 收益走势">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">📊</div>
|
||||
<p class="text-gray-600">投资收益趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🥧 资产配置">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">🍰</div>
|
||||
<p class="text-gray-600">资产配置分布图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
210
apps/web-antd/src/views/finance/reimbursement/approval.vue
Normal file
210
apps/web-antd/src/views/finance/reimbursement/approval.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementApproval' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
const pendingStatuses: FinanceApi.TransactionStatus[] = ['draft', 'pending'];
|
||||
|
||||
const reimbursements = computed(() =>
|
||||
financeStore.reimbursements
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.isDeleted && pendingStatuses.includes(item.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.transactionDate).getTime() -
|
||||
new Date(b.transactionDate).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys as number[];
|
||||
},
|
||||
}));
|
||||
|
||||
const columns: TableColumnsType<FinanceApi.Transaction> = [
|
||||
{
|
||||
title: '报销事项',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '报销日期',
|
||||
dataIndex: 'transactionDate',
|
||||
key: 'transactionDate',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'submittedBy',
|
||||
key: 'submittedBy',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function refresh() {
|
||||
await financeStore.fetchReimbursements();
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
record: FinanceApi.Transaction,
|
||||
status: FinanceApi.TransactionStatus,
|
||||
reviewNotes?: string | null,
|
||||
) {
|
||||
await financeStore.updateReimbursement(record.id, {
|
||||
status,
|
||||
reviewNotes: reviewNotes ?? record.reviewNotes ?? null,
|
||||
});
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
function handleApprove(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'approved');
|
||||
}
|
||||
|
||||
function handleReject(record: FinanceApi.Transaction) {
|
||||
let notes = record.reviewNotes ?? '';
|
||||
Modal.confirm({
|
||||
title: '驳回报销申请',
|
||||
content: () =>
|
||||
h(Input.TextArea, {
|
||||
value: notes,
|
||||
rows: 3,
|
||||
onChange(event: any) {
|
||||
notes = event.target.value;
|
||||
},
|
||||
placeholder: '请输入驳回原因',
|
||||
}),
|
||||
okText: '确认驳回',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await updateStatus(record, 'rejected', notes);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBulkApprove() {
|
||||
if (selectedRowKeys.value.length === 0) return;
|
||||
const list = financeStore.reimbursements.filter((item) =>
|
||||
selectedRowKeys.value.includes(item.id),
|
||||
);
|
||||
await Promise.all(
|
||||
list.map((item) =>
|
||||
financeStore.updateReimbursement(item.id, { status: 'approved' }),
|
||||
),
|
||||
);
|
||||
message.success('已批量通过审批');
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
function handleView(record: FinanceApi.Transaction) {
|
||||
router.push(`/reimbursement/detail/${record.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card>
|
||||
<template #title>审批队列</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button :loading="loading" @click="refresh">刷新</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
@click="handleBulkApprove"
|
||||
>
|
||||
批量通过
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="reimbursements"
|
||||
:loading="loading"
|
||||
:row-key="(record) => record.id"
|
||||
:row-selection="rowSelection"
|
||||
bordered
|
||||
:scroll="{ x: 820 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span class="font-medium">
|
||||
{{ record.amount.toFixed(2) }} {{ record.currency }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="formatStatus(record.status).color">
|
||||
{{ formatStatus(record.status).label }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="handleApprove(record)">
|
||||
通过
|
||||
</Button>
|
||||
<Button size="small" danger @click="handleReject(record)">
|
||||
驳回
|
||||
</Button>
|
||||
<Button size="small" type="link" @click="handleView(record)">
|
||||
查看详情
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<span>当前没有待审批的报销申请</span>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
316
apps/web-antd/src/views/finance/reimbursement/create.vue
Normal file
316
apps/web-antd/src/views/finance/reimbursement/create.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_OPTIONS } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementCreate' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
|
||||
interface FormState {
|
||||
transactionDate: Dayjs | null;
|
||||
description: string;
|
||||
project?: string;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
amount: number | null;
|
||||
currency: string;
|
||||
memo?: string;
|
||||
submittedBy?: string;
|
||||
reimbursementBatch?: string;
|
||||
status: FinanceApi.TransactionStatus;
|
||||
}
|
||||
|
||||
const formState = reactive<FormState>({
|
||||
transactionDate: dayjs(),
|
||||
description: '',
|
||||
project: '',
|
||||
categoryId: undefined,
|
||||
accountId: undefined,
|
||||
amount: null,
|
||||
currency: 'CNY',
|
||||
memo: '',
|
||||
submittedBy: '',
|
||||
reimbursementBatch: '',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
transactionDate: [{ required: true, message: '请选择报销日期' }],
|
||||
description: [{ required: true, message: '请填写报销内容' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入报销金额' },
|
||||
{
|
||||
validator(_: unknown, value: number) {
|
||||
if (value && value > 0) return Promise.resolve();
|
||||
return Promise.reject(new Error('金额必须大于 0'));
|
||||
},
|
||||
},
|
||||
],
|
||||
currency: [{ required: true, message: '请选择币种' }],
|
||||
};
|
||||
|
||||
const currencyOptions = computed(() =>
|
||||
financeStore.currencies.map((item) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code,
|
||||
})),
|
||||
);
|
||||
|
||||
const accountOptions = computed(() =>
|
||||
financeStore.accounts.map((item) => ({
|
||||
label: `${item.name} · ${item.currency}`,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
financeStore.expenseCategories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
async function ensureBaseData() {
|
||||
await Promise.all([
|
||||
financeStore.currencies.length === 0
|
||||
? financeStore.fetchCurrencies()
|
||||
: Promise.resolve(),
|
||||
financeStore.accounts.length === 0
|
||||
? financeStore.fetchAccounts()
|
||||
: Promise.resolve(),
|
||||
financeStore.expenseCategories.length === 0
|
||||
? financeStore.fetchCategories()
|
||||
: Promise.resolve(),
|
||||
financeStore.reimbursements.length === 0
|
||||
? financeStore.fetchReimbursements()
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildPayload(): FinanceApi.CreateReimbursementParams {
|
||||
return {
|
||||
amount: Number(formState.amount),
|
||||
transactionDate: formState.transactionDate
|
||||
? formState.transactionDate.format('YYYY-MM-DD')
|
||||
: dayjs().format('YYYY-MM-DD'),
|
||||
description: formState.description,
|
||||
project: formState.project || undefined,
|
||||
categoryId: formState.categoryId,
|
||||
accountId: formState.accountId,
|
||||
currency: formState.currency,
|
||||
memo: formState.memo || undefined,
|
||||
submittedBy: formState.submittedBy || undefined,
|
||||
reimbursementBatch: formState.reimbursementBatch || undefined,
|
||||
status: formState.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const reimbursement = await financeStore.createReimbursement(payload);
|
||||
message.success('报销申请创建成功');
|
||||
router.push(`/reimbursement/detail/${reimbursement.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('创建报销申请失败,请稍后重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields();
|
||||
formState.transactionDate = dayjs();
|
||||
formState.description = '';
|
||||
formState.project = '';
|
||||
formState.categoryId = undefined;
|
||||
formState.accountId = undefined;
|
||||
formState.amount = null;
|
||||
formState.currency = 'CNY';
|
||||
formState.memo = '';
|
||||
formState.submittedBy = '';
|
||||
formState.reimbursementBatch = '';
|
||||
formState.status = 'pending';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureBaseData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<Card class="max-w-4xl mx-auto">
|
||||
<template #title>创建报销申请</template>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
autocomplete="off"
|
||||
class="space-y-4"
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销日期" name="transactionDate">
|
||||
<DatePicker
|
||||
v-model:value="formState.transactionDate"
|
||||
class="w-full"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销状态" name="status">
|
||||
<Select
|
||||
v-model:value="formState.status"
|
||||
:options="STATUS_OPTIONS"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="报销内容" name="description">
|
||||
<Input
|
||||
v-model:value="formState.description"
|
||||
placeholder="例如:办公设备采购、客户招待等"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="所属项目" name="project">
|
||||
<Input
|
||||
v-model:value="formState.project"
|
||||
placeholder="可填写项目名称或成本中心"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="费用分类" name="categoryId">
|
||||
<Select
|
||||
v-model:value="formState.categoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="请选择费用分类"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="支付账号" name="accountId">
|
||||
<Select
|
||||
v-model:value="formState.accountId"
|
||||
:options="accountOptions"
|
||||
placeholder="请选择支出账号"
|
||||
allow-clear
|
||||
show-search
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="提交人" name="submittedBy">
|
||||
<Input
|
||||
v-model:value="formState.submittedBy"
|
||||
placeholder="填写报销提交人或责任人"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销金额" name="amount">
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="币种" name="currency">
|
||||
<Select
|
||||
v-model:value="formState.currency"
|
||||
:options="currencyOptions"
|
||||
placeholder="请选择币种"
|
||||
show-search
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="报销批次" name="reimbursementBatch">
|
||||
<Input
|
||||
v-model:value="formState.reimbursementBatch"
|
||||
placeholder="可填写批次号或审批编号"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="备注" name="memo">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.memo"
|
||||
:rows="4"
|
||||
placeholder="补充说明、附件信息等"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
提交
|
||||
</Button>
|
||||
<Button :disabled="submitting" @click="resetForm">重置</Button>
|
||||
<Button :disabled="submitting" @click="handleCancel">取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
383
apps/web-antd/src/views/finance/reimbursement/detail.vue
Normal file
383
apps/web-antd/src/views/finance/reimbursement/detail.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_OPTIONS, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const reimbursementId = Number(route.params.id);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const reimbursement = computed(() =>
|
||||
financeStore.reimbursements.find((item) => item.id === reimbursementId),
|
||||
);
|
||||
|
||||
const formState = reactive({
|
||||
description: '',
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
project: '',
|
||||
memo: '',
|
||||
submittedBy: '',
|
||||
approvedBy: '',
|
||||
reimbursementBatch: '',
|
||||
reviewNotes: '',
|
||||
status: 'pending' as FinanceApi.TransactionStatus,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
description: [{ required: true, message: '请填写报销内容' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入金额' },
|
||||
{
|
||||
validator(_: unknown, value: number) {
|
||||
if (value && value > 0) return Promise.resolve();
|
||||
return Promise.reject(new Error('金额需大于 0'));
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const canEdit = computed(() => !reimbursement.value?.isDeleted);
|
||||
|
||||
watch(
|
||||
reimbursement,
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
formState.description = value.description;
|
||||
formState.amount = value.amount;
|
||||
formState.currency = value.currency;
|
||||
formState.project = value.project ?? '';
|
||||
formState.memo = value.memo ?? '';
|
||||
formState.submittedBy = value.submittedBy ?? '';
|
||||
formState.approvedBy = value.approvedBy ?? '';
|
||||
formState.reimbursementBatch = value.reimbursementBatch ?? '';
|
||||
formState.reviewNotes = value.reviewNotes ?? '';
|
||||
formState.status = value.status;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function ensureData() {
|
||||
if (
|
||||
financeStore.reimbursements.length === 0 ||
|
||||
!reimbursement.value
|
||||
) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await financeStore.fetchReimbursements({ includeDeleted: true });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!reimbursement.value) return;
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
description: formState.description,
|
||||
amount: formState.amount,
|
||||
currency: formState.currency,
|
||||
project: formState.project || null,
|
||||
memo: formState.memo || null,
|
||||
submittedBy: formState.submittedBy || null,
|
||||
approvedBy: formState.approvedBy || null,
|
||||
reimbursementBatch: formState.reimbursementBatch || null,
|
||||
reviewNotes: formState.reviewNotes || null,
|
||||
status: formState.status,
|
||||
});
|
||||
message.success('信息已更新');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('更新失败,请稍后重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'approved',
|
||||
});
|
||||
message.success('已审批通过');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('审批失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'rejected',
|
||||
reviewNotes: formState.reviewNotes || reimbursement.value.reviewNotes,
|
||||
});
|
||||
message.success('已驳回');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('驳回失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'paid',
|
||||
});
|
||||
message.success('已标记为报销完成');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('操作失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.restoreReimbursement(reimbursement.value.id);
|
||||
message.success('已恢复报销单');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('恢复失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.deleteReimbursement(reimbursement.value.id);
|
||||
message.success('已移入回收站');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('操作失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ensureData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Button @click="goBack">返回</Button>
|
||||
|
||||
<Card v-if="loading">
|
||||
<span>加载中...</span>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="!reimbursement">
|
||||
<Space direction="vertical">
|
||||
<span>未找到报销单记录,可能已被删除或编号错误。</span>
|
||||
<Button type="primary" @click="goBack">返回列表</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Card>
|
||||
<template #title>报销概览</template>
|
||||
<Space direction="vertical" class="w-full">
|
||||
<Space align="center">
|
||||
<Tag :color="formatStatus(reimbursement.status).color">
|
||||
{{ formatStatus(reimbursement.status).label }}
|
||||
</Tag>
|
||||
<span class="text-gray-500 text-sm">
|
||||
创建于 {{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
|
||||
</span>
|
||||
</Space>
|
||||
<Descriptions :column="1" size="small" bordered>
|
||||
<Descriptions.Item label="报销内容">
|
||||
{{ reimbursement.description || '未填写' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="报销日期">
|
||||
{{ dayjs(reimbursement.transactionDate).format('YYYY-MM-DD') }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{{ reimbursement.amount.toFixed(2) }} {{ reimbursement.currency }}
|
||||
(折合 {{ reimbursement.amountInBase.toFixed(2) }} CNY)
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属项目">
|
||||
{{ reimbursement.project || '未指定' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交人">
|
||||
{{ reimbursement.submittedBy || '未填写' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批人">
|
||||
{{ reimbursement.approvedBy || '未指定' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批备注">
|
||||
{{ reimbursement.reviewNotes || '暂无' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="批次号">
|
||||
{{ reimbursement.reimbursementBatch || '未设置' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>更新报销信息</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button type="primary" :loading="saving" :disabled="!canEdit" @click="handleSave">
|
||||
保存变更
|
||||
</Button>
|
||||
<Button :disabled="!canEdit" @click="handleApprove">
|
||||
审批通过
|
||||
</Button>
|
||||
<Button danger :disabled="!canEdit" @click="handleReject">
|
||||
驳回
|
||||
</Button>
|
||||
<Button :disabled="!canEdit" @click="handleMarkPaid">
|
||||
标记已报销
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="!reimbursement.isDeleted"
|
||||
title="确定要删除该报销单?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<Button danger type="link">移入回收站</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
v-else
|
||||
type="link"
|
||||
@click="handleRestore"
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<Form.Item label="报销内容" name="description">
|
||||
<Input
|
||||
v-model:value="formState.description"
|
||||
:disabled="!canEdit"
|
||||
placeholder="描述报销事项"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="金额" name="amount">
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
:disabled="!canEdit"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="币种" name="currency">
|
||||
<Input
|
||||
v-model:value="formState.currency"
|
||||
:disabled="!canEdit"
|
||||
placeholder="例如 CNY"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="所属项目" name="project">
|
||||
<Input
|
||||
v-model:value="formState.project"
|
||||
:disabled="!canEdit"
|
||||
placeholder="可填写项目或成本中心"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="memo">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.memo"
|
||||
:disabled="!canEdit"
|
||||
:rows="3"
|
||||
placeholder="补充说明"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="提交人" name="submittedBy">
|
||||
<Input
|
||||
v-model:value="formState.submittedBy"
|
||||
:disabled="!canEdit"
|
||||
placeholder="报销申请人"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="审批人" name="approvedBy">
|
||||
<Input
|
||||
v-model:value="formState.approvedBy"
|
||||
:disabled="!canEdit"
|
||||
placeholder="审批负责人"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="审批备注" name="reviewNotes">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.reviewNotes"
|
||||
:rows="3"
|
||||
:disabled="!canEdit"
|
||||
placeholder="审批意见、驳回原因等"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="批次号" name="reimbursementBatch">
|
||||
<Input
|
||||
v-model:value="formState.reimbursementBatch"
|
||||
:disabled="!canEdit"
|
||||
placeholder="报销批次或审批编号"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
v-model:value="formState.status"
|
||||
:options="STATUS_OPTIONS"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
429
apps/web-antd/src/views/finance/reimbursement/index.vue
Normal file
429
apps/web-antd/src/views/finance/reimbursement/index.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Dropdown,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_CONFIG, STATUS_OPTIONS, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementList' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<FinanceApi.TransactionStatus[]>(
|
||||
STATUS_OPTIONS.map((item) => item.value),
|
||||
);
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
const includeDeleted = ref(false);
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
const batchStatus = ref<FinanceApi.TransactionStatus>('approved');
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
|
||||
const reimbursements = computed(() =>
|
||||
financeStore.reimbursements.slice().sort((a, b) => {
|
||||
const statusOrder =
|
||||
STATUS_CONFIG[a.status].order - STATUS_CONFIG[b.status].order;
|
||||
if (statusOrder !== 0) {
|
||||
return statusOrder;
|
||||
}
|
||||
return dayjs(b.transactionDate).valueOf() -
|
||||
dayjs(a.transactionDate).valueOf();
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredReimbursements = computed(() => {
|
||||
return reimbursements.value.filter((item) => {
|
||||
if (!includeDeleted.value && item.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
statusFilter.value.length > 0 &&
|
||||
!statusFilter.value.includes(item.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value;
|
||||
const date = dayjs(item.transactionDate);
|
||||
if (
|
||||
date.isBefore(start.startOf('day')) ||
|
||||
date.isAfter(end.endOf('day'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (keyword.value.trim().length > 0) {
|
||||
const text = keyword.value.trim().toLowerCase();
|
||||
const matcher = [
|
||||
item.description,
|
||||
item.project,
|
||||
item.memo,
|
||||
item.submittedBy,
|
||||
item.reimbursementBatch,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!matcher.includes(text)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const summary = new Map<
|
||||
FinanceApi.TransactionStatus,
|
||||
{ count: number; amount: number; baseAmount: number }
|
||||
>();
|
||||
for (const status of Object.keys(STATUS_CONFIG) as FinanceApi.TransactionStatus[]) {
|
||||
summary.set(status, { count: 0, amount: 0, baseAmount: 0 });
|
||||
}
|
||||
reimbursements.value.forEach((item) => {
|
||||
const target = summary.get(item.status);
|
||||
if (!target) return;
|
||||
target.count += 1;
|
||||
target.amount += item.amount;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
return Array.from(summary.entries()).sort(
|
||||
(a, b) => STATUS_CONFIG[a[0]].order - STATUS_CONFIG[b[0]].order,
|
||||
);
|
||||
});
|
||||
|
||||
const columns: TableColumnsType<FinanceApi.Transaction> = [
|
||||
{
|
||||
title: '报销内容',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'transactionDate',
|
||||
key: 'transactionDate',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '项目/分类',
|
||||
key: 'project',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'submittedBy',
|
||||
key: 'submittedBy',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '批次',
|
||||
dataIndex: 'reimbursementBatch',
|
||||
key: 'reimbursementBatch',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 220,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys as number[];
|
||||
},
|
||||
}));
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = STATUS_OPTIONS.map((item) => item.value);
|
||||
dateRange.value = null;
|
||||
includeDeleted.value = false;
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
record: FinanceApi.Transaction,
|
||||
status: FinanceApi.TransactionStatus,
|
||||
extra: Partial<FinanceApi.CreateReimbursementParams> = {},
|
||||
) {
|
||||
await financeStore.updateReimbursement(record.id, {
|
||||
status,
|
||||
...extra,
|
||||
});
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
function handleApprove(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'approved');
|
||||
}
|
||||
|
||||
function handleReject(record: FinanceApi.Transaction) {
|
||||
let notes = record.reviewNotes ?? '';
|
||||
Modal.confirm({
|
||||
title: '确认驳回报销申请?',
|
||||
content: () =>
|
||||
h(Input.TextArea, {
|
||||
value: notes,
|
||||
placeholder: '请输入驳回原因',
|
||||
rows: 3,
|
||||
onChange: (event: any) => {
|
||||
notes = event.target.value;
|
||||
},
|
||||
}),
|
||||
okText: '驳回',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await updateStatus(record, 'rejected', { reviewNotes: notes });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleMarkPaid(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'paid', {
|
||||
approvedBy: record.approvedBy ?? 'system',
|
||||
});
|
||||
}
|
||||
|
||||
function handleMoveToPending(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'pending');
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(status: FinanceApi.TransactionStatus) {
|
||||
if (selectedRowKeys.value.length === 0) return;
|
||||
const targets = financeStore.reimbursements.filter((item) =>
|
||||
selectedRowKeys.value.includes(item.id),
|
||||
);
|
||||
await Promise.all(
|
||||
targets.map((item) =>
|
||||
financeStore.updateReimbursement(item.id, { status }),
|
||||
),
|
||||
);
|
||||
message.success('批量操作完成');
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
function handleView(record: FinanceApi.Transaction) {
|
||||
router.push(`/reimbursement/detail/${record.id}`);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
router.push('/reimbursement/create');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
await financeStore.fetchReimbursements();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card>
|
||||
<template #title>报销概览</template>
|
||||
<Row :gutter="16">
|
||||
<Col
|
||||
v-for="[status, item] in statusSummary"
|
||||
:key="status"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<Card size="small" :bordered="false">
|
||||
<Space direction="vertical" class="w-full">
|
||||
<Space align="center">
|
||||
<Tag :color="formatStatus(status).color">
|
||||
{{ formatStatus(status).label }}
|
||||
</Tag>
|
||||
<span class="text-xs text-gray-500">{{
|
||||
formatStatus(status).description
|
||||
}}</span>
|
||||
</Space>
|
||||
<Statistic title="单据数量" :value="item.count" />
|
||||
<Statistic
|
||||
title="金额(原币)"
|
||||
:precision="2"
|
||||
:value="item.amount"
|
||||
/>
|
||||
<Statistic
|
||||
title="金额(折合CNY)"
|
||||
:precision="2"
|
||||
:value="item.baseAmount"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>筛选条件</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button type="link" @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="handleCreate">新增报销</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<Space :size="12" wrap>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
placeholder="搜索描述 / 项目 / 提交人"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="STATUS_OPTIONS"
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
placeholder="选择状态"
|
||||
style="min-width: 220px"
|
||||
/>
|
||||
<RangePicker
|
||||
v-model:value="dateRange"
|
||||
allow-clear
|
||||
placeholder="请选择日期范围"
|
||||
/>
|
||||
<Space align="center">
|
||||
<Switch v-model:checked="includeDeleted" size="small" />
|
||||
<span class="text-xs text-gray-500">显示已删除</span>
|
||||
</Space>
|
||||
<Select
|
||||
v-model:value="batchStatus"
|
||||
:options="STATUS_OPTIONS"
|
||||
style="min-width: 160px"
|
||||
/>
|
||||
<Button
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
@click="handleBulkUpdate(batchStatus)"
|
||||
>
|
||||
批量更新状态
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>报销列表</template>
|
||||
<template #extra>
|
||||
<span class="text-sm text-gray-500"
|
||||
>共 {{ filteredReimbursements.length }} 条记录</span
|
||||
>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="filteredReimbursements"
|
||||
:loading="loading"
|
||||
:row-key="(record) => record.id"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 960 }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="formatStatus(record.status).color">
|
||||
{{ formatStatus(record.status).label }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<Space direction="vertical" size="small">
|
||||
<span class="font-medium">
|
||||
{{ record.amount.toFixed(2) }} {{ record.currency }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
折合 {{ record.amountInBase.toFixed(2) }} CNY
|
||||
</span>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'project'">
|
||||
<Space direction="vertical" size="small">
|
||||
<span class="font-medium">{{ record.project || '未指定' }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ record.memo || '无备注' }}
|
||||
</span>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space>
|
||||
<Button size="small" type="link" @click="handleView(record)">
|
||||
查看
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="approve"
|
||||
@click="handleApprove(record)"
|
||||
>
|
||||
审批通过
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="reject"
|
||||
danger
|
||||
@click="handleReject(record)"
|
||||
>
|
||||
驳回
|
||||
</Menu.Item>
|
||||
<Menu.Item key="paid" @click="handleMarkPaid(record)">
|
||||
标记报销
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="pending"
|
||||
@click="handleMoveToPending(record)"
|
||||
>
|
||||
重新提交
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button size="small">更多</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
206
apps/web-antd/src/views/finance/reimbursement/statistics.vue
Normal file
206
apps/web-antd/src/views/finance/reimbursement/statistics.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, Progress, Skeleton, Statistic, Table, Tag } from 'ant-design-vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_CONFIG, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementStatistics' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const summary = new Map<
|
||||
string,
|
||||
{ count: number; baseAmount: number }
|
||||
>();
|
||||
(Object.keys(STATUS_CONFIG) as Array<keyof typeof STATUS_CONFIG>).forEach(
|
||||
(status) => {
|
||||
summary.set(status, { count: 0, baseAmount: 0 });
|
||||
},
|
||||
);
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const key = summary.get(item.status);
|
||||
if (!key) return;
|
||||
key.count += 1;
|
||||
key.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
const totalCount = Array.from(summary.values()).reduce(
|
||||
(acc, cur) => acc + cur.count,
|
||||
0,
|
||||
);
|
||||
return Array.from(summary.entries()).map(([status, value]) => ({
|
||||
status,
|
||||
count: value.count,
|
||||
baseAmount: value.baseAmount,
|
||||
percentage: totalCount === 0 ? 0 : Math.round((value.count / totalCount) * 100),
|
||||
}));
|
||||
});
|
||||
|
||||
const monthlySummary = computed(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ total: number; baseAmount: number; approved: number; pending: number }
|
||||
>();
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const month = item.transactionDate.slice(0, 7);
|
||||
if (!map.has(month)) {
|
||||
map.set(month, { total: 0, baseAmount: 0, approved: 0, pending: 0 });
|
||||
}
|
||||
const target = map.get(month)!;
|
||||
target.total += item.amount;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
if (item.status === 'approved' || item.status === 'paid') {
|
||||
target.approved += item.amountInBase ?? 0;
|
||||
}
|
||||
if (item.status === 'draft' || item.status === 'pending') {
|
||||
target.pending += item.amountInBase ?? 0;
|
||||
}
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([month, value]) => ({
|
||||
month,
|
||||
...value,
|
||||
}))
|
||||
.sort((a, b) => (a.month < b.month ? 1 : -1))
|
||||
.slice(0, 12);
|
||||
});
|
||||
|
||||
const projectSummary = computed(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ count: number; baseAmount: number }
|
||||
>();
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const key = item.project || '未指定项目';
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { count: 0, baseAmount: 0 });
|
||||
}
|
||||
const target = map.get(key)!;
|
||||
target.count += 1;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([project, value]) => ({
|
||||
project,
|
||||
...value,
|
||||
}))
|
||||
.sort((a, b) => b.baseAmount - a.baseAmount)
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
financeStore.fetchReimbursements();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card title="状态分布">
|
||||
<Skeleton :loading="loading" active>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="item in statusSummary"
|
||||
:key="item.status"
|
||||
class="border rounded-md p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Tag :color="formatStatus(item.status).color">
|
||||
{{ formatStatus(item.status).label }}
|
||||
</Tag>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatStatus(item.status).description }}
|
||||
</span>
|
||||
</div>
|
||||
<Statistic title="单据数量" :value="item.count" />
|
||||
<Statistic
|
||||
title="折合金额 (CNY)"
|
||||
:precision="2"
|
||||
:value="item.baseAmount"
|
||||
/>
|
||||
<Progress :percent="item.percentage" />
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
|
||||
<Card title="按月统计(最近12个月)">
|
||||
<Skeleton :loading="loading" active>
|
||||
<Table
|
||||
:data-source="monthlySummary"
|
||||
:pagination="false"
|
||||
row-key="month"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<Table.Column title="月份" dataIndex="month" key="month" />
|
||||
<Table.Column
|
||||
title="原币合计"
|
||||
key="total"
|
||||
customRender="total"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.total.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="折合CNY"
|
||||
key="baseAmount"
|
||||
customRender="baseAmount"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.baseAmount.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="已批准金额 (CNY)"
|
||||
key="approved"
|
||||
customRender="approved"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.approved.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="待审批金额 (CNY)"
|
||||
key="pending"
|
||||
customRender="pending"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.pending.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
|
||||
<Card title="项目费用 Top 10">
|
||||
<Skeleton :loading="loading" active>
|
||||
<Table
|
||||
:data-source="projectSummary"
|
||||
:pagination="false"
|
||||
row-key="project"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<Table.Column title="项目" dataIndex="project" key="project" />
|
||||
<Table.Column title="单据数量" dataIndex="count" key="count" />
|
||||
<Table.Column
|
||||
title="折合金额 (CNY)"
|
||||
key="baseAmount"
|
||||
customRender="baseAmount"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.baseAmount.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
48
apps/web-antd/src/views/finance/reimbursement/status.ts
Normal file
48
apps/web-antd/src/views/finance/reimbursement/status.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
FinanceApi.TransactionStatus,
|
||||
{ label: string; color: string; description: string; order: number }
|
||||
> = {
|
||||
draft: {
|
||||
label: '草稿',
|
||||
color: 'default',
|
||||
description: '尚未提交或待完善的报销信息',
|
||||
order: 0,
|
||||
},
|
||||
pending: {
|
||||
label: '待审批',
|
||||
color: 'processing',
|
||||
description: '等待审批人审核的报销申请',
|
||||
order: 1,
|
||||
},
|
||||
approved: {
|
||||
label: '已通过',
|
||||
color: 'success',
|
||||
description: '审批通过,待支付或报销完成',
|
||||
order: 2,
|
||||
},
|
||||
rejected: {
|
||||
label: '已驳回',
|
||||
color: 'error',
|
||||
description: '审批被驳回,需要发起人处理',
|
||||
order: 3,
|
||||
},
|
||||
paid: {
|
||||
label: '已报销',
|
||||
color: 'purple',
|
||||
description: '已完成报销或费用报销入账',
|
||||
order: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG)
|
||||
.sort((a, b) => a[1].order - b[1].order)
|
||||
.map(([value, config]) => ({
|
||||
label: `${config.label}`,
|
||||
value: value as FinanceApi.TransactionStatus,
|
||||
}));
|
||||
|
||||
export function formatStatus(status: FinanceApi.TransactionStatus) {
|
||||
return STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
|
||||
}
|
||||
@@ -233,19 +233,19 @@ const exportToExcel = (title: string, timestamp: string) => {
|
||||
['指标', '金额', '', ''],
|
||||
[
|
||||
'总收入',
|
||||
`¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'总支出',
|
||||
`¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'净收入',
|
||||
`¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
@@ -314,9 +314,9 @@ const exportToCSV = (title: string, timestamp: string) => {
|
||||
if (exportOptions.value.includeSummary) {
|
||||
csvContent += '核心指标汇总\n';
|
||||
csvContent += '指标,金额\n';
|
||||
csvContent += `总收入,¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总支出,¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `净收入,¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总收入,$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总支出,$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `净收入,$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `交易笔数,${periodTransactions.value.length}\n\n`;
|
||||
}
|
||||
|
||||
@@ -401,15 +401,15 @@ const printReport = () => {
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div class="label">总收入</div>
|
||||
<div class="value income">¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value income">$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">总支出</div>
|
||||
<div class="value expense">¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value expense">$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">净收入</div>
|
||||
<div class="value net">${periodNet.value >= 0 ? '+' : ''}¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value net">${periodNet.value >= 0 ? '+' : ''}$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">交易笔数</div>
|
||||
@@ -427,9 +427,9 @@ const printReport = () => {
|
||||
(item) => `
|
||||
<div class="category-item">
|
||||
<span class="category-name">${item.categoryName}</span>
|
||||
<span class="category-amount income">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<span class="category-amount income">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -450,9 +450,9 @@ const printReport = () => {
|
||||
(item) => `
|
||||
<div class="category-item">
|
||||
<span class="category-name">${item.categoryName}</span>
|
||||
<span class="category-amount expense">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<span class="category-amount expense">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -488,7 +488,7 @@ const printReport = () => {
|
||||
<td>${t.description || ''}</td>
|
||||
<td>${getCategoryName(t.categoryId)}</td>
|
||||
<td class="${t.type === 'income' ? 'income' : 'expense'}">
|
||||
${t.type === 'income' ? '+' : '-'}¥${Math.abs(t.amount).toLocaleString()}
|
||||
${t.type === 'income' ? '+' : '-'}$${Math.abs(t.amount).toLocaleString()}
|
||||
</td>
|
||||
<td>${getAccountName(t.accountId)}</td>
|
||||
</tr>
|
||||
@@ -696,7 +696,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -705,7 +705,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 text-3xl">💸</div>
|
||||
<p class="text-sm text-gray-500">总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -717,7 +717,7 @@ onMounted(async () => {
|
||||
class="text-2xl font-bold"
|
||||
:class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'"
|
||||
>
|
||||
{{ periodNet >= 0 ? '+' : '' }}¥{{
|
||||
{{ periodNet >= 0 ? '+' : '' }}${{
|
||||
periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -748,7 +748,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium">{{ item.categoryName }}</span>
|
||||
<span class="text-sm font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
item.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -767,7 +767,7 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} 笔 · 平均 ¥{{
|
||||
{{ item.count }} 笔 · 平均 ${{
|
||||
(item.amount / item.count).toFixed(2)
|
||||
}}
|
||||
</p>
|
||||
@@ -790,7 +790,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium">{{ item.categoryName }}</span>
|
||||
<span class="text-sm font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
item.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -809,7 +809,7 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} 笔 · 平均 ¥{{
|
||||
{{ item.count }} 笔 · 平均 ${{
|
||||
(item.amount / item.count).toFixed(2)
|
||||
}}
|
||||
</p>
|
||||
@@ -840,7 +840,7 @@ onMounted(async () => {
|
||||
: 'font-bold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.type === 'income' ? '+' : '-' }}¥{{
|
||||
{{ record.type === 'income' ? '+' : '-' }}${{
|
||||
Math.abs(record.amount).toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
|
||||
@@ -167,7 +167,7 @@ const smartInsights = computed(() => {
|
||||
type: 'expense_trend',
|
||||
icon: '📉',
|
||||
title: '支出趋势',
|
||||
description: `本期总支出 ¥${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
description: `本期总支出 $${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
value: `${currentPeriodData.value.expenseCount} 笔`,
|
||||
trend: null,
|
||||
valueClass: 'text-red-600',
|
||||
@@ -213,7 +213,7 @@ const smartInsights = computed(() => {
|
||||
icon: '💎',
|
||||
title: '平均单笔',
|
||||
description: '本期平均每笔支出金额',
|
||||
value: `¥${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
value: `$${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
trend: null,
|
||||
valueClass: 'text-purple-600',
|
||||
trendClass: '',
|
||||
@@ -432,7 +432,7 @@ const initCashFlowChart = () => {
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
formatter: '${value}',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
@@ -496,7 +496,7 @@ const initExpenseTreeChart = () => {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: ¥{c} ({d}%)',
|
||||
formatter: '{b}: ${c} ({d}%)',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -505,7 +505,7 @@ const initExpenseTreeChart = () => {
|
||||
leafDepth: 1,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n¥{c}',
|
||||
formatter: '{b}\n${c}',
|
||||
},
|
||||
upperLabel: {
|
||||
show: true,
|
||||
@@ -823,7 +823,9 @@ window.addEventListener('resize', () => {
|
||||
style="width: 200px"
|
||||
/>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="quarterly" tab="📊 季度分析">
|
||||
@@ -845,7 +847,9 @@ window.addEventListener('resize', () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="yearly" tab="📈 年度分析">
|
||||
@@ -867,7 +871,9 @@ window.addEventListener('resize', () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="custom" tab="🎯 自定义">
|
||||
@@ -877,7 +883,9 @@ window.addEventListener('resize', () => {
|
||||
format="YYYY-MM-DD"
|
||||
@change="handleCustomRangeChange"
|
||||
/>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
@@ -907,7 +915,9 @@ window.addEventListener('resize', () => {
|
||||
class="text-sm"
|
||||
:class="insight.trendClass"
|
||||
>
|
||||
<template v-if="insight.trend > 0">↗ +{{ insight.trend }}%</template>
|
||||
<template v-if="insight.trend > 0"
|
||||
>↗ +{{ insight.trend }}%</template
|
||||
>
|
||||
<template v-else>↘ {{ insight.trend }}%</template>
|
||||
</span>
|
||||
</div>
|
||||
@@ -922,7 +932,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">期间总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.totalIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -946,7 +956,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">💸</div>
|
||||
<p class="text-sm text-gray-500">期间总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.totalExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -970,7 +980,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">平均单笔金额</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.avgAmount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -986,7 +996,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">🏆</div>
|
||||
<p class="text-sm text-gray-500">最大单笔支出</p>
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.maxExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1109,7 +1119,7 @@ window.addEventListener('resize', () => {
|
||||
<div>
|
||||
<p class="font-semibold">{{ health.categoryName }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
¥{{
|
||||
${{
|
||||
health.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1156,7 +1166,7 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-red-600">
|
||||
-¥{{
|
||||
-${{
|
||||
anomaly.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1208,7 +1218,7 @@ window.addEventListener('resize', () => {
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'income'">
|
||||
<span class="font-semibold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
record.income.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1217,7 +1227,7 @@ window.addEventListener('resize', () => {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'expense'">
|
||||
<span class="font-semibold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
record.expense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1229,7 +1239,7 @@ window.addEventListener('resize', () => {
|
||||
:class="record.net >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="font-bold"
|
||||
>
|
||||
{{ record.net >= 0 ? '+' : '' }}¥{{
|
||||
{{ record.net >= 0 ? '+' : '' }}${{
|
||||
record.net.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</span>
|
||||
@@ -1265,7 +1275,7 @@ window.addEventListener('resize', () => {
|
||||
>
|
||||
<div v-if="record[column.dataIndex]">
|
||||
<div class="font-semibold">
|
||||
¥{{
|
||||
${{
|
||||
record[column.dataIndex].amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1279,7 +1289,7 @@ window.addEventListener('resize', () => {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total'">
|
||||
<span class="font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
record.total.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
@@ -1,38 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, Tag } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'TaxManagement' });
|
||||
|
||||
// 税务统计(空数据)
|
||||
const taxStats = ref({
|
||||
yearlyIncome: 0,
|
||||
paidTax: 0,
|
||||
potentialSaving: 0,
|
||||
filingStatus: 'pending',
|
||||
});
|
||||
|
||||
// 节税建议(空数据)
|
||||
const taxTips = ref([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🧾 税务管理</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🧾 税务管理</h1>
|
||||
<p class="text-gray-600">个人所得税计算、申报和税务优化建议</p>
|
||||
</div>
|
||||
|
||||
<!-- 税务概览 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">年度收入</p>
|
||||
<p class="text-2xl font-bold text-blue-600">¥{{ taxStats.yearlyIncome.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
${{ taxStats.yearlyIncome.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🧾</div>
|
||||
<p class="text-sm text-gray-500">已缴税额</p>
|
||||
<p class="text-2xl font-bold text-red-600">¥{{ taxStats.paidTax.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
${{ taxStats.paidTax.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💡</div>
|
||||
<p class="text-sm text-gray-500">可节税</p>
|
||||
<p class="text-2xl font-bold text-green-600">¥{{ taxStats.potentialSaving.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
${{ taxStats.potentialSaving.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📅</div>
|
||||
<p class="text-sm text-gray-500">申报状态</p>
|
||||
<Tag :color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'">
|
||||
<Tag
|
||||
:color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'"
|
||||
>
|
||||
{{ taxStats.filingStatus === 'completed' ? '已申报' : '待申报' }}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -40,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 税务工具 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card title="🧮 个税计算器">
|
||||
<div class="space-y-4">
|
||||
<Input placeholder="月收入" />
|
||||
@@ -51,21 +78,27 @@
|
||||
</Card>
|
||||
|
||||
<Card title="📊 纳税分析">
|
||||
<div class="h-48 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-48 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">📈</div>
|
||||
<div class="mb-2 text-3xl">📈</div>
|
||||
<p class="text-gray-600">税负分析图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="💡 节税建议">
|
||||
<div v-if="taxTips.length === 0" class="text-center py-6">
|
||||
<div class="text-3xl mb-2">💡</div>
|
||||
<div v-if="taxTips.length === 0" class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">💡</div>
|
||||
<p class="text-gray-500">暂无节税建议</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="tip in taxTips" :key="tip.id" class="p-3 bg-blue-50 rounded-lg">
|
||||
<div
|
||||
v-for="tip in taxTips"
|
||||
:key="tip.id"
|
||||
class="rounded-lg bg-blue-50 p-3"
|
||||
>
|
||||
<p class="text-sm font-medium text-blue-800">{{ tip.title }}</p>
|
||||
<p class="text-xs text-blue-600">{{ tip.description }}</p>
|
||||
</div>
|
||||
@@ -75,24 +108,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Tag, Input, Button } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'TaxManagement' });
|
||||
|
||||
// 税务统计(空数据)
|
||||
const taxStats = ref({
|
||||
yearlyIncome: 0,
|
||||
paidTax: 0,
|
||||
potentialSaving: 0,
|
||||
filingStatus: 'pending'
|
||||
});
|
||||
|
||||
// 节税建议(空数据)
|
||||
const taxTips = ref([]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, Select } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceTools' });
|
||||
|
||||
// 贷款计算器表单
|
||||
const loanForm = ref({
|
||||
amount: '',
|
||||
rate: '',
|
||||
years: '',
|
||||
});
|
||||
|
||||
const loanResult = ref({
|
||||
monthlyPayment: null,
|
||||
});
|
||||
|
||||
// 投资计算器表单
|
||||
const investmentForm = ref({
|
||||
initial: '',
|
||||
rate: '',
|
||||
years: '',
|
||||
});
|
||||
|
||||
const investmentResult = ref({
|
||||
finalValue: null,
|
||||
});
|
||||
|
||||
// 汇率换算表单
|
||||
const currencyForm = ref({
|
||||
amount: '',
|
||||
from: 'CNY',
|
||||
to: 'USD',
|
||||
});
|
||||
|
||||
const currencyResult = ref({
|
||||
converted: null,
|
||||
});
|
||||
|
||||
// 计算方法
|
||||
const calculateLoan = () => {
|
||||
const amount = Number.parseFloat(loanForm.value.amount);
|
||||
const rate = Number.parseFloat(loanForm.value.rate) / 100 / 12;
|
||||
const months = Number.parseInt(loanForm.value.years) * 12;
|
||||
|
||||
if (amount && rate && months) {
|
||||
const monthlyPayment =
|
||||
(amount * rate * (1 + rate) ** months) / ((1 + rate) ** months - 1);
|
||||
loanResult.value.monthlyPayment = monthlyPayment;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateInvestment = () => {
|
||||
const initial = Number.parseFloat(investmentForm.value.initial);
|
||||
const rate = Number.parseFloat(investmentForm.value.rate) / 100;
|
||||
const years = Number.parseInt(investmentForm.value.years);
|
||||
|
||||
if (initial && rate && years) {
|
||||
const finalValue = initial * (1 + rate) ** years;
|
||||
investmentResult.value.finalValue = finalValue;
|
||||
}
|
||||
};
|
||||
|
||||
const convertCurrency = () => {
|
||||
const amount = Number.parseFloat(currencyForm.value.amount);
|
||||
// 模拟汇率(实际应用中应该调用汇率API)
|
||||
const rate =
|
||||
currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD'
|
||||
? 0.14
|
||||
: 7.15;
|
||||
|
||||
if (amount) {
|
||||
currencyResult.value.converted = (amount * rate).toFixed(2);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🛠️ 财务工具</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🛠️ 财务工具</h1>
|
||||
<p class="text-gray-600">实用的财务计算和分析工具</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card title="🏠 贷款计算器">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="loanForm.amount" placeholder="请输入贷款金额" />
|
||||
<Input v-model:value="loanForm.rate" placeholder="请输入年利率 %" />
|
||||
<Input v-model:value="loanForm.years" placeholder="请输入贷款年限" />
|
||||
<Button type="primary" block @click="calculateLoan">计算月供</Button>
|
||||
<div v-if="loanResult.monthlyPayment" class="mt-4 p-3 bg-blue-50 rounded-lg text-center">
|
||||
<p class="font-medium text-blue-800">月供:¥{{ loanResult.monthlyPayment.toLocaleString() }}</p>
|
||||
<div
|
||||
v-if="loanResult.monthlyPayment"
|
||||
class="mt-4 rounded-lg bg-blue-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-blue-800">
|
||||
月供:${{ loanResult.monthlyPayment.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card title="📈 投资计算器">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="investmentForm.initial" placeholder="请输入初始投资金额" />
|
||||
<Input v-model:value="investmentForm.rate" placeholder="请输入年收益率 %" />
|
||||
<Input v-model:value="investmentForm.years" placeholder="请输入投资期限(年)" />
|
||||
<Button type="primary" block @click="calculateInvestment">计算收益</Button>
|
||||
<div v-if="investmentResult.finalValue" class="mt-4 p-3 bg-green-50 rounded-lg text-center">
|
||||
<p class="font-medium text-green-800">预期收益:¥{{ investmentResult.finalValue.toLocaleString() }}</p>
|
||||
<Input
|
||||
v-model:value="investmentForm.initial"
|
||||
placeholder="请输入初始投资金额"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="investmentForm.rate"
|
||||
placeholder="请输入年收益率 %"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="investmentForm.years"
|
||||
placeholder="请输入投资期限(年)"
|
||||
/>
|
||||
<Button type="primary" block @click="calculateInvestment">
|
||||
计算收益
|
||||
</Button>
|
||||
<div
|
||||
v-if="investmentResult.finalValue"
|
||||
class="mt-4 rounded-lg bg-green-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-green-800">
|
||||
预期收益:${{ investmentResult.finalValue.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card title="💱 汇率换算">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="currencyForm.amount" placeholder="请输入金额" />
|
||||
<Select v-model:value="currencyForm.from" placeholder="原币种" style="width: 100%">
|
||||
<Select
|
||||
v-model:value="currencyForm.from"
|
||||
placeholder="原币种"
|
||||
style="width: 100%"
|
||||
>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
</Select>
|
||||
<Select v-model:value="currencyForm.to" placeholder="目标币种" style="width: 100%">
|
||||
<Select
|
||||
v-model:value="currencyForm.to"
|
||||
placeholder="目标币种"
|
||||
style="width: 100%"
|
||||
>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" block @click="convertCurrency">立即换算</Button>
|
||||
<div v-if="currencyResult.converted" class="mt-4 p-3 bg-purple-50 rounded-lg text-center">
|
||||
<Button type="primary" block @click="convertCurrency">
|
||||
立即换算
|
||||
</Button>
|
||||
<div
|
||||
v-if="currencyResult.converted"
|
||||
class="mt-4 rounded-lg bg-purple-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-purple-800">
|
||||
{{ currencyForm.amount }} {{ currencyForm.from }} = {{ currencyResult.converted }} {{ currencyForm.to }}
|
||||
{{ currencyForm.amount }} {{ currencyForm.from }} =
|
||||
{{ currencyResult.converted }} {{ currencyForm.to }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,79 +168,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Input, Button, Select } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceTools' });
|
||||
|
||||
// 贷款计算器表单
|
||||
const loanForm = ref({
|
||||
amount: '',
|
||||
rate: '',
|
||||
years: ''
|
||||
});
|
||||
|
||||
const loanResult = ref({
|
||||
monthlyPayment: null
|
||||
});
|
||||
|
||||
// 投资计算器表单
|
||||
const investmentForm = ref({
|
||||
initial: '',
|
||||
rate: '',
|
||||
years: ''
|
||||
});
|
||||
|
||||
const investmentResult = ref({
|
||||
finalValue: null
|
||||
});
|
||||
|
||||
// 汇率换算表单
|
||||
const currencyForm = ref({
|
||||
amount: '',
|
||||
from: 'CNY',
|
||||
to: 'USD'
|
||||
});
|
||||
|
||||
const currencyResult = ref({
|
||||
converted: null
|
||||
});
|
||||
|
||||
// 计算方法
|
||||
const calculateLoan = () => {
|
||||
const amount = parseFloat(loanForm.value.amount);
|
||||
const rate = parseFloat(loanForm.value.rate) / 100 / 12;
|
||||
const months = parseInt(loanForm.value.years) * 12;
|
||||
|
||||
if (amount && rate && months) {
|
||||
const monthlyPayment = (amount * rate * Math.pow(1 + rate, months)) / (Math.pow(1 + rate, months) - 1);
|
||||
loanResult.value.monthlyPayment = monthlyPayment;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateInvestment = () => {
|
||||
const initial = parseFloat(investmentForm.value.initial);
|
||||
const rate = parseFloat(investmentForm.value.rate) / 100;
|
||||
const years = parseInt(investmentForm.value.years);
|
||||
|
||||
if (initial && rate && years) {
|
||||
const finalValue = initial * Math.pow(1 + rate, years);
|
||||
investmentResult.value.finalValue = finalValue;
|
||||
}
|
||||
};
|
||||
|
||||
const convertCurrency = () => {
|
||||
const amount = parseFloat(currencyForm.value.amount);
|
||||
// 模拟汇率(实际应用中应该调用汇率API)
|
||||
const rate = currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD' ? 0.14 : 7.15;
|
||||
|
||||
if (amount) {
|
||||
currencyResult.value.converted = (amount * rate).toFixed(2);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1289,7 +1289,7 @@ const _handleAccountChange = (account: string) => {
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
statistics.totalIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -1303,7 +1303,7 @@ const _handleAccountChange = (account: string) => {
|
||||
<div class="text-3xl">📉</div>
|
||||
<p class="text-sm text-gray-500">总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
statistics.totalExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -1322,7 +1322,7 @@ const _handleAccountChange = (account: string) => {
|
||||
statistics.netIncome >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
¥{{
|
||||
${{
|
||||
statistics.netIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
|
||||
Reference in New Issue
Block a user