移除对账功能 后期从长计议
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 1m57s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-01-27 15:29:25 +08:00
parent bade93ad57
commit 4aa7e82429
29 changed files with 716 additions and 328 deletions

202
AGENTS.md Normal file
View File

@@ -0,0 +1,202 @@
# AGENTS.md - EmailBill Project Guidelines
Full-stack budget tracking app with .NET 10 backend and Vue 3 frontend.
## Project Structure
```
EmailBill/
├── Common/ # Shared utilities and abstractions
├── Entity/ # Database entities (FreeSql ORM)
├── Repository/ # Data access layer
├── Service/ # Business logic layer
├── WebApi/ # ASP.NET Core Web API
├── WebApi.Test/ # Backend tests (xUnit)
└── Web/ # Vue 3 frontend (Vite + Vant UI)
```
## Build & Test Commands
### Backend (.NET 10)
```bash
# Build and run
dotnet build EmailBill.sln
dotnet run --project WebApi/WebApi.csproj
# Run all tests
dotnet test WebApi.Test/WebApi.Test.csproj
# Run single test class
dotnet test --filter "FullyQualifiedName~BudgetStatsTest"
# Run single test method
dotnet test --filter "FullyQualifiedName~BudgetStatsTest.GetCategoryStats_月度_Test"
# Clean
dotnet clean EmailBill.sln
```
### Frontend (Vue 3)
```bash
cd Web
# Setup and dev
pnpm install
pnpm dev
# Build and preview
pnpm build
pnpm preview
# Lint and format
pnpm lint # ESLint with auto-fix
pnpm format # Prettier formatting
```
## C# Code Style
**Namespaces & Imports:**
- File-scoped namespaces: `namespace Entity;`
- Global usings in `Common/GlobalUsings.cs`
- Sort using statements alphabetically
**Naming:**
- Classes/Methods: `PascalCase`
- Interfaces: `IPascalCase`
- Private fields: `_camelCase`
- Parameters/locals: `camelCase`
**Entities:**
- Inherit from `BaseEntity`
- Use `[Column]` attributes for FreeSql ORM
- IDs via Snowflake: `YitIdHelper.NextId()`
- Use XML docs (`///`) for public APIs
- **Chinese comments for business logic** (per `.github/csharpe.prompt.md`)
**Best Practices:**
- Use modern C# syntax (records, pattern matching, nullable types)
- Use `IDateTimeProvider` instead of `DateTime.Now` for testability
- Avoid deep nesting, keep code flat and readable
- Reuse utilities from `Common` project
**Example:**
```csharp
namespace Entity;
/// <summary>
/// 实体基类
/// </summary>
public abstract class BaseEntity
{
[Column(IsPrimary = true)]
public long Id { get; set; } = YitIdHelper.NextId();
public DateTime CreateTime { get; set; } = DateTime.Now;
}
```
## Vue/TypeScript Style
**Component Structure:**
```vue
<template>
<van-config-provider :theme="theme">
<div class="component-name">
<!-- Content -->
</div>
</van-config-provider>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMessageStore } from '@/stores/message'
const messageStore = useMessageStore()
</script>
<style scoped lang="scss">
.component-name {
padding: 16px;
}
</style>
```
**Rules:**
- Composition API with `<script setup lang="ts">`
- Import order: Vue APIs → external libs → internal modules
- Use `@/` alias for absolute imports, avoid `../../../`
- Vant UI components: `<van-*>`
- Pinia for state, Vue Router for navigation
- SCSS with BEM naming, mobile-first design
**ESLint Rules (see `Web/eslint.config.js`):**
- 2-space indentation
- Single quotes, no semicolons
- `const` over `let`, no `var`
- Always use `===` (strict equality)
- `space-before-function-paren: 'always'`
- Max 1 empty line between blocks
- Vue: multi-word component names disabled
**Prettier Rules (see `Web/.prettierrc.json`):**
- Single quotes, no semicolons
- Trailing commas: none
- Print width: 100 chars
## Testing
**Backend (xUnit + NSubstitute + FluentAssertions):**
```csharp
public class BudgetStatsTest : BaseTest
{
private readonly IBudgetRepository _repo = Substitute.For<IBudgetRepository>();
[Fact]
public async Task GetCategoryStats_月度_Test()
{
// Arrange
_repo.GetAllAsync().Returns(testData);
// Act
var result = await _service.GetCategoryStatsAsync(category, date);
// Assert
result.Month.Limit.Should().Be(2500);
}
}
```
- Arrange-Act-Assert pattern
- Constructor injection for dependencies
- Use Chinese test method names for domain clarity
**Frontend:**
- Vue Test Utils for components
- axios-mock-adapter for API mocking
## Development Workflow
1. **Before committing backend:** `dotnet test`
2. **Before committing frontend:** `pnpm lint && pnpm build`
3. **Database migrations:** Use FreeSql (check `Repository/`)
4. **API docs:** Scalar OpenAPI viewer
## Environment
**Required:**
- .NET 10 SDK
- Node.js 20.19+ or 22.12+
- pnpm
**Database:** SQLite (embedded)
**Config:**
- Backend: `appsettings.json`
- Frontend: `.env.development` / `.env.production`
## Critical Guidelines (from `.github/csharpe.prompt.md`)
- 优先使用新C#语法 (Use modern C# syntax)
- 优先使用中文注释 (Prefer Chinese comments for business logic)
- 优先复用已有方法 (Reuse existing methods)
- 不要深嵌套代码 (Avoid deep nesting)
- 保持代码简洁易读 (Keep code clean and readable)

View File

@@ -1,4 +1,4 @@
namespace Entity;
namespace Entity;
/// <summary>
/// 银行交易记录(由邮件解析生成)
@@ -18,12 +18,7 @@ public class TransactionRecord : BaseEntity
/// <summary>
/// 交易金额
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 退款金额
/// </summary>
public decimal RefundAmount { get; set; }
public decimal Amount { get; set; }
/// <summary>
/// 交易后余额

View File

@@ -1,4 +1,4 @@
namespace Repository;
namespace Repository;
public interface ITransactionRecordRepository : IBaseRepository<TransactionRecord>
{
@@ -176,16 +176,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <param name="minMatchRate">最小匹配率0.0-1.0默认0.3表示至少匹配30%的关键词</param>
/// <param name="limit">返回结果数量限制</param>
/// <returns>带相关度分数的已分类账单列表</returns>
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
/// <summary>
/// 获取抵账候选列表
/// </summary>
/// <param name="currentId">当前交易ID</param>
/// <param name="amount">当前交易金额</param>
/// <param name="currentType">当前交易类型</param>
/// <returns>候选交易列表</returns>
Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType);
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
/// <summary>
/// 获取待确认分类的账单列表
@@ -673,34 +664,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.Take(limit)
.ToList();
return scoredResults;
}
public async Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType)
{
var absAmount = Math.Abs(amount);
var minAmount = absAmount - 5;
var maxAmount = absAmount + 5;
var currentRecord = await FreeSql.Select<TransactionRecord>()
.Where(t => t.Id == currentId)
.FirstAsync();
if (currentRecord == null)
{
return [];
}
var list = await FreeSql.Select<TransactionRecord>()
.Where(t => t.Id != currentId)
.Where(t => t.Type != currentType)
.Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
.Take(50)
.ToListAsync();
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
.ThenBy(x => Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
.ToList();
return scoredResults;
}
public async Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type)
@@ -769,7 +733,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
{
g.Key.Classify,
g.Key.Type,
TotalAmount = g.Sum(g.Value.Amount - g.Value.RefundAmount)
TotalAmount = g.Sum(g.Value.Amount)
});
return result.ToDictionary(x => (x.Classify, x.Type), x => x.TotalAmount);

View File

@@ -1,4 +1,4 @@
namespace Service;
namespace Service;
public interface ISmartHandleService
{

View File

@@ -4,7 +4,7 @@
* 获取预算列表
* @param {string} referenceDate 参考日期 (可选)
*/
export function getBudgetList(referenceDate) {
export function getBudgetList (referenceDate) {
return request({
url: '/Budget/GetList',
method: 'get',
@@ -16,7 +16,7 @@ export function getBudgetList(referenceDate) {
* 创建预算
* @param {object} data 预算数据
*/
export function createBudget(data) {
export function createBudget (data) {
return request({
url: '/Budget/Create',
method: 'post',
@@ -28,7 +28,7 @@ export function createBudget(data) {
* 更新预算
* @param {object} data 预算数据
*/
export function updateBudget(data) {
export function updateBudget (data) {
return request({
url: '/Budget/Update',
method: 'post',
@@ -40,7 +40,7 @@ export function updateBudget(data) {
* 删除预算
* @param {number} id 预算ID
*/
export function deleteBudget(id) {
export function deleteBudget (id) {
return request({
url: `/Budget/DeleteById/${id}`,
method: 'delete'
@@ -52,7 +52,7 @@ export function deleteBudget(id) {
* @param {string} category 分类 (Expense/Income/Savings)
* @param {string} referenceDate 参考日期 (可选)
*/
export function getCategoryStats(category, referenceDate) {
export function getCategoryStats (category, referenceDate) {
return request({
url: '/Budget/GetCategoryStats',
method: 'get',
@@ -64,7 +64,7 @@ export function getCategoryStats(category, referenceDate) {
* @param {number} category 预算分类
* @param {string} referenceDate 参考日期
*/
export function getUncoveredCategories(category, referenceDate) {
export function getUncoveredCategories (category, referenceDate) {
return request({
url: '/Budget/GetUncoveredCategories',
method: 'get',
@@ -76,7 +76,7 @@ export function getUncoveredCategories(category, referenceDate) {
* 获取归档总结
* @param {string} referenceDate 参考日期
*/
export function getArchiveSummary(referenceDate) {
export function getArchiveSummary (referenceDate) {
return request({
url: '/Budget/GetArchiveSummary',
method: 'get',
@@ -88,7 +88,7 @@ export function getArchiveSummary(referenceDate) {
* 更新归档总结
* @param {object} data 数据 { referenceDate, summary }
*/
export function updateArchiveSummary(data) {
export function updateArchiveSummary (data) {
return request({
url: '/Budget/UpdateArchiveSummary',
method: 'post',
@@ -102,7 +102,7 @@ export function updateArchiveSummary(data) {
* @param {number} month 月份
* @param {number} type 周期类型 (1:Month, 2:Year)
*/
export function getSavingsBudget(year, month, type) {
export function getSavingsBudget (year, month, type) {
return request({
url: '/Budget/GetSavingsBudget',
method: 'get',

View File

@@ -1,13 +1,13 @@
import request from './request'
export function getVapidPublicKey() {
export function getVapidPublicKey () {
return request({
url: '/Notification/GetVapidPublicKey',
method: 'get'
})
}
export function subscribe(data) {
export function subscribe (data) {
return request({
url: '/Notification/Subscribe',
method: 'post',
@@ -15,7 +15,7 @@ export function subscribe(data) {
})
}
export function testNotification(message) {
export function testNotification (message) {
return request({
url: '/Notification/TestNotification',
method: 'post',

View File

@@ -14,7 +14,7 @@ const request = axios.create({
// 生成请求ID
const generateRequestId = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
@@ -29,11 +29,11 @@ request.interceptors.request.use(
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
// 添加请求ID
const requestId = generateRequestId()
config.headers['X-Request-ID'] = requestId
return config
},
(error) => {

View File

@@ -87,8 +87,6 @@ export const getDailyStatistics = (params) => {
})
}
/**
* 获取累积余额统计数据(用于余额卡片)
* @param {Object} params - 查询参数

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
/**
* 交易记录相关 API
@@ -223,32 +223,6 @@ export const nlpAnalysis = (userInput) => {
})
}
/**
* 获取抵账候选列表
* @param {number} id - 当前交易ID
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getCandidatesForOffset = (id) => {
return request({
url: `/TransactionRecord/GetCandidatesForOffset/${id}`,
method: 'get'
})
}
/**
* 抵账(删除两笔交易)
* @param {number} id1 - 交易ID 1
* @param {number} id2 - 交易ID 2
* @returns {Promise<{success: boolean}>}
*/
export const offsetTransactions = (id1, id2) => {
return request({
url: '/TransactionRecord/OffsetTransactions',
method: 'post',
data: { id1, id2 }
})
}
/**
* 一句话录账解析
* @param {string} text - 用户输入的自然语言文本

View File

@@ -38,30 +38,58 @@
/>
<!-- 交易类型 -->
<van-field name="type" label="类型">
<van-field
name="type"
label="类型"
>
<template #input>
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
<van-radio :name="0"> 支出 </van-radio>
<van-radio :name="1"> 收入 </van-radio>
<van-radio :name="2"> 不计 </van-radio>
<van-radio-group
v-model="form.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类 -->
<van-field name="category" label="分类">
<van-field
name="category"
label="分类"
>
<template #input>
<span v-if="!categoryName" style="color: var(--van-text-color-3)">请选择分类</span>
<span
v-if="!categoryName"
style="color: var(--van-text-color-3)"
>请选择分类</span>
<span v-else>{{ categoryName }}</span>
</template>
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector v-model="categoryName" :type="form.type" />
<ClassifySelector
v-model="categoryName"
:type="form.type"
/>
</van-cell-group>
<div class="actions">
<van-button round block type="primary" native-type="submit" :loading="loading">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
>
{{ submitText }}
</van-button>
<slot name="actions" />
@@ -69,7 +97,12 @@
</van-form>
<!-- 日期选择弹窗 -->
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body">
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
teleport="body"
>
<van-date-picker
v-model="currentDate"
title="选择日期"
@@ -79,7 +112,12 @@
</van-popup>
<!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
<van-popup
v-model:show="showTimePicker"
position="bottom"
round
teleport="body"
>
<van-time-picker
v-model="currentTime"
title="选择时间"

View File

@@ -1,6 +1,10 @@
<template>
<div class="manual-bill-add">
<BillForm ref="billFormRef" :loading="saving" @submit="handleSave" />
<BillForm
ref="billFormRef"
:loading="saving"
@submit="handleSave"
/>
</div>
</template>

View File

@@ -1,6 +1,10 @@
<template>
<div>
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px">
<div
v-if="!parseResult"
class="input-section"
style="margin: 12px 12px 0 16px"
>
<van-field
v-model="text"
type="textarea"
@@ -23,7 +27,10 @@
</div>
</div>
<div v-if="parseResult" class="result-section">
<div
v-if="parseResult"
class="result-section"
>
<BillForm
:initial-data="parseResult"
:loading="saving"
@@ -31,7 +38,13 @@
@submit="handleSave"
>
<template #actions>
<van-button plain round block class="mt-2" @click="parseResult = null">
<van-button
plain
round
block
class="mt-2"
@click="parseResult = null"
>
重新输入
</van-button>
</template>

View File

@@ -1,21 +1,35 @@
<template>
<div class="summary-container">
<transition :name="transitionName" mode="out-in">
<transition
:name="transitionName"
mode="out-in"
>
<div
v-if="stats && (stats.month || stats.year)"
:key="dateKey"
class="summary-card common-card"
>
<!-- 左切换按钮 -->
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
<div
class="nav-arrow left"
@click.stop="changeMonth(-1)"
>
<van-icon name="arrow-left" />
</div>
<div class="summary-content">
<template v-for="(config, key) in periodConfigs" :key="key">
<template
v-for="(config, key) in periodConfigs"
:key="key"
>
<div class="summary-item">
<div class="label">{{ config.label }}{{ title }}</div>
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')">
<div class="label">
{{ config.label }}{{ title }}
</div>
<div
class="value"
:class="getValueClass(stats[key]?.rate || '0.0')"
>
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div>
<div class="sub-info">
@@ -24,7 +38,10 @@
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div>
</div>
<div v-if="config.showDivider" class="divider" />
<div
v-if="config.showDivider"
class="divider"
/>
</template>
</div>
@@ -38,7 +55,10 @@
</div>
<!-- 非本月时显示的日期标识 -->
<div v-if="!isCurrentMonth" class="date-tag">
<div
v-if="!isCurrentMonth"
class="date-tag"
>
{{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }}
</div>
</div>

View File

@@ -1,12 +1,20 @@
<template>
<PopupContainer v-model="visible" title="设置存款分类" height="60%">
<PopupContainer
v-model="visible"
title="设置存款分类"
height="60%"
>
<div class="savings-config-content">
<div class="config-header">
<p class="subtitle">这些分类的统计值将计入存款</p>
<p class="subtitle">
这些分类的统计值将计入存款
</p>
</div>
<div class="category-section">
<div class="section-title">可多选分类</div>
<div class="section-title">
可多选分类
</div>
<ClassifySelector
v-model="selectedCategories"
:type="2"
@@ -18,7 +26,14 @@
</div>
<template #footer>
<van-button block round type="primary" @click="onSubmit"> 保存配置 </van-button>
<van-button
block
round
type="primary"
@click="onSubmit"
>
保存配置
</van-button>
</template>
</PopupContainer>
</template>

View File

@@ -1,18 +1,40 @@
<template>
<div class="global-add-bill">
<!-- Floating Add Bill Button -->
<div class="floating-add" @click="openAddBill">
<div
class="floating-add"
@click="openAddBill"
>
<van-icon name="plus" />
</div>
<!-- Add Bill Modal -->
<PopupContainer v-model="showAddBill" title="记一笔" height="75%">
<van-tabs v-model:active="activeTab" shrink>
<van-tab title="一句话录账" name="one">
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
<PopupContainer
v-model="showAddBill"
title="记一笔"
height="75%"
>
<van-tabs
v-model:active="activeTab"
shrink
>
<van-tab
title="一句话录账"
name="one"
>
<OneLineBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
<van-tab title="手动录账" name="manual">
<ManualBillAdd :key="componentKey" @success="handleSuccess" />
<van-tab
title="手动录账"
name="manual"
>
<ManualBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
</van-tabs>
</PopupContainer>

View File

@@ -12,21 +12,36 @@
<!-- 头部区域 -->
<div class="popup-header-fixed">
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
<div
class="header-title-row"
:class="{ 'has-actions': !subtitle && hasActions }"
>
<h3 class="popup-title">
{{ title }}
</h3>
<!-- 无子标题时操作按钮与标题同行 -->
<div v-if="!subtitle && hasActions" class="header-actions-inline">
<div
v-if="!subtitle && hasActions"
class="header-actions-inline"
>
<slot name="header-actions" />
</div>
</div>
<!-- 子标题/统计信息 -->
<div v-if="subtitle" class="header-stats">
<span class="stats-text" v-html="subtitle" />
<div
v-if="subtitle"
class="header-stats"
>
<span
class="stats-text"
v-html="subtitle"
/>
<!-- 额外操作插槽 -->
<slot v-if="hasActions" name="header-actions" />
<slot
v-if="hasActions"
name="header-actions"
/>
</div>
</div>
@@ -36,7 +51,10 @@
</div>
<!-- 底部页脚固定不可滚动 -->
<div v-if="slots.footer" class="popup-footer-fixed">
<div
v-if="slots.footer"
class="popup-footer-fixed"
>
<slot name="footer" />
</div>
</div>

View File

@@ -1,15 +1,22 @@
<template>
<PopupContainer v-model="visible" title="交易详情" height="75%" :closeable="false">
<template #header-actions>
<van-button size="small" type="primary" plain @click="handleOffsetClick"> 抵账 </van-button>
</template>
<template>
<PopupContainer
v-model="visible"
title="交易详情"
height="75%"
:closeable="false"
>
<van-form style="margin-top: 12px">
<van-cell-group inset>
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
<van-cell
title="记录时间"
:value="formatDate(transaction.createTime)"
/>
</van-cell-group>
<van-cell-group inset title="交易明细">
<van-cell-group
inset
title="交易明细"
>
<van-field
v-model="occurredAtLabel"
name="occurredAt"
@@ -48,50 +55,68 @@
:rules="[{ required: true, message: '请输入交易后余额' }]"
/>
<van-field name="type" label="交易类型">
<van-field
name="type"
label="交易类型"
>
<template #input>
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio :name="0"> 支出 </van-radio>
<van-radio :name="1"> 收入 </van-radio>
<van-radio :name="2"> 不计 </van-radio>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field name="classify" label="交易分类">
<van-field
name="classify"
label="交易分类"
>
<template #input>
<div style="flex: 1">
<div
v-if="
transaction &&
transaction.unconfirmedClassify &&
transaction.unconfirmedClassify !== editForm.classify
transaction.unconfirmedClassify &&
transaction.unconfirmedClassify !== editForm.classify
"
class="suggestion-tip"
@click="applySuggestion"
>
<van-icon name="bulb-o" class="suggestion-icon" />
<van-icon
name="bulb-o"
class="suggestion-icon"
/>
<span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }}
<span
v-if="
transaction.unconfirmedType !== null &&
transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type
transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type
"
>
({{ getTypeName(transaction.unconfirmedType) }})
</span>
</span>
<div class="suggestion-apply">应用</div>
<div class="suggestion-apply">
应用
</div>
</div>
<span v-else-if="!editForm.classify" style="color: var(--van-gray-5)"
>请选择交易分类</span
>
<span
v-else-if="!editForm.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
</div>
</template>
@@ -106,30 +131,25 @@
</van-form>
<template #footer>
<van-button round block type="primary" :loading="submitting" @click="onSubmit">
<van-button
round
block
type="primary"
:loading="submitting"
@click="onSubmit"
>
保存修改
</van-button>
</template>
</PopupContainer>
<!-- 抵账候选列表弹窗 -->
<PopupContainer v-model="showOffsetPopup" title="选择抵账交易" height="75%">
<van-list>
<van-cell
v-for="item in offsetCandidates"
:key="item.id"
:title="item.reason"
:label="formatDate(item.occurredAt)"
:value="item.amount"
is-link
@click="handleCandidateSelect(item)"
/>
<van-empty v-if="offsetCandidates.length === 0" description="暂无匹配的抵账交易" />
</van-list>
</PopupContainer>
<!-- 日期选择弹窗 -->
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body">
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
teleport="body"
>
<van-date-picker
v-model="currentDate"
title="选择日期"
@@ -139,7 +159,12 @@
</van-popup>
<!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
<van-popup
v-model:show="showTimePicker"
position="bottom"
round
teleport="body"
>
<van-time-picker
v-model="currentTime"
title="选择时间"
@@ -151,14 +176,12 @@
<script setup>
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { showToast } from 'vant'
import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import {
updateTransaction,
getCandidatesForOffset,
offsetTransactions
updateTransaction
} from '@/api/transactionRecord'
const props = defineProps({
@@ -341,50 +364,6 @@ const formatDate = (dateString) => {
})
}
// 抵账相关
const showOffsetPopup = ref(false)
const offsetCandidates = ref([])
const handleOffsetClick = async () => {
try {
const res = await getCandidatesForOffset(editForm.id)
if (res.success) {
offsetCandidates.value = res.data || []
showOffsetPopup.value = true
} else {
showToast(res.message || '获取抵账列表失败')
}
} catch (error) {
console.error('获取抵账列表出错:', error)
showToast('获取抵账列表失败')
}
}
const handleCandidateSelect = (candidate) => {
showConfirmDialog({
title: '确认抵账',
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗\n抵消后两笔交易将被删除`
})
.then(async () => {
try {
const res = await offsetTransactions(editForm.id, candidate.id)
if (res.success) {
showToast('抵账成功')
showOffsetPopup.value = false
visible.value = false
emit('save') // 触发列表刷新
} else {
showToast(res.message || '抵账失败')
}
} catch (error) {
console.error('抵账出错:', error)
showToast('抵账失败')
}
})
.catch(() => {
// on cancel
})
}
</script>
<style scoped>

View File

@@ -1,7 +1,16 @@
<template>
<template>
<div class="transaction-list-container transaction-list">
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
<van-list
:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell-group
v-if="transactions && transactions.length"
inset
style="margin-top: 10px"
>
<van-swipe-cell
v-for="transaction in transactions"
:key="transaction.id"
@@ -14,7 +23,10 @@
class="checkbox-col"
@update:model-value="toggleSelection(transaction)"
/>
<div class="transaction-card" @click="handleClick(transaction)">
<div
class="transaction-card"
@click="handleClick(transaction)"
>
<div class="card-left">
<div class="transaction-title">
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
@@ -26,28 +38,36 @@
<span
v-if="
transaction.upsetedClassify &&
transaction.upsetedClassify !== transaction.classify
transaction.upsetedClassify !== transaction.classify
"
style="color: var(--van-warning-color)"
>
→ {{ transaction.upsetedClassify }}
</span>
</div>
<div v-if="transaction.importFrom">来源: {{ transaction.importFrom }}</div>
<div v-if="transaction.importFrom">
来源: {{ transaction.importFrom }}
</div>
</div>
</div>
<div class="card-middle">
<van-tag :type="getTypeTagType(transaction.type)" size="medium">
<van-tag
:type="getTypeTagType(transaction.type)"
size="medium"
>
{{ getTypeName(transaction.type) }}
</van-tag>
<template
v-if="
Number.isFinite(transaction.upsetedType) &&
transaction.upsetedType !== transaction.type
transaction.upsetedType !== transaction.type
"
>
<van-tag :type="getTypeTagType(transaction.upsetedType)" size="medium">
<van-tag
:type="getTypeTagType(transaction.upsetedType)"
size="medium"
>
{{ getTypeName(transaction.upsetedType) }}
</van-tag>
</template>
@@ -57,21 +77,25 @@
<div :class="['amount', getAmountClass(transaction.type)]">
{{ formatAmount(transaction.amount, transaction.type) }}
</div>
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
余额: {{ formatMoney(transaction.balance) }}
</div>
<div
v-if="transaction.refundAmount && transaction.refundAmount > 0"
v-if="transaction.balance && transaction.balance > 0"
class="balance"
>
退款: {{ formatMoney(transaction.refundAmount) }}
余额: {{ formatMoney(transaction.balance) }}
</div>
</div>
<van-icon name="arrow" size="16" color="var(--van-gray-5)" />
<van-icon
name="arrow"
size="16"
color="var(--van-gray-5)"
/>
</div>
</div>
</div>
<template v-if="showDelete" #right>
<template
v-if="showDelete"
#right
>
<van-button
square
type="danger"

View File

@@ -3,13 +3,13 @@
export const needRefresh = ref(false)
let swRegistration = null
export async function updateServiceWorker() {
export async function updateServiceWorker () {
if (swRegistration && swRegistration.waiting) {
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
}
}
export function register() {
export function register () {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = '/service-worker.js'
@@ -66,7 +66,7 @@ export function register() {
}
}
export function unregister() {
export function unregister () {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
@@ -79,7 +79,7 @@ export function unregister() {
}
// 请求通知权限
export function requestNotificationPermission() {
export function requestNotificationPermission () {
if ('Notification' in window && 'serviceWorker' in navigator) {
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
@@ -90,7 +90,7 @@ export function requestNotificationPermission() {
}
// 后台同步
export function registerBackgroundSync(tag = 'sync-data') {
export function registerBackgroundSync (tag = 'sync-data') {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready
.then((registration) => {

View File

@@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
function increment () {
count.value++
}

View File

@@ -1,7 +1,10 @@
<template>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
<van-nav-bar title="账单" placeholder>
<van-nav-bar
title="账单"
placeholder
>
<template #right>
<van-button
v-if="tabActive === 'email'"
@@ -20,15 +23,38 @@
/>
</template>
</van-nav-bar>
<van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0">
<van-tab title="账单" name="balance" />
<van-tab title="邮件" name="email" />
<van-tab title="消息" name="message" />
<van-tabs
v-model:active="tabActive"
type="card"
style="margin: 12px 0 2px 0"
>
<van-tab
title="账单"
name="balance"
/>
<van-tab
title="邮件"
name="email"
/>
<van-tab
title="消息"
name="message"
/>
</van-tabs>
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef" />
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
<TransactionsRecord
v-if="tabActive === 'balance'"
ref="transactionsRecordRef"
/>
<EmailRecord
v-else-if="tabActive === 'email'"
ref="emailRecordRef"
/>
<MessageView
v-else-if="tabActive === 'message'"
ref="messageViewRef"
:is-component="true"
/>
</div>
</template>

View File

@@ -15,7 +15,10 @@
</div>
<!-- 分组列表 -->
<van-empty v-if="!hasData && finished" description="暂无数据" />
<van-empty
v-if="!hasData && finished"
description="暂无数据"
/>
<van-list
v-model:loading="listLoading"

View File

@@ -10,7 +10,10 @@
<div class="scroll-content">
<!-- 第一层选择交易类型 -->
<div v-if="currentLevel === 0" class="level-container">
<div
v-if="currentLevel === 0"
class="level-container"
>
<van-cell-group inset>
<van-cell
v-for="type in typeOptions"
@@ -23,22 +26,48 @@
</div>
<!-- 第二层分类列表 -->
<div v-else class="level-container">
<div
v-else
class="level-container"
>
<!-- 面包屑导航 -->
<div class="breadcrumb">
<van-tag type="primary" closeable style="margin-left: 16px" @close="handleBackToRoot">
<van-tag
type="primary"
closeable
style="margin-left: 16px"
@close="handleBackToRoot"
>
{{ currentTypeName }}
</van-tag>
</div>
<!-- 分类列表 -->
<van-empty v-if="categories.length === 0" description="暂无分类" />
<van-empty
v-if="categories.length === 0"
description="暂无分类"
/>
<van-cell-group v-else inset>
<van-swipe-cell v-for="category in categories" :key="category.id">
<van-cell :title="category.name" is-link @click="handleEdit(category)" />
<van-cell-group
v-else
inset
>
<van-swipe-cell
v-for="category in categories"
:key="category.id"
>
<van-cell
:title="category.name"
is-link
@click="handleEdit(category)"
/>
<template #right>
<van-button square type="danger" text="删除" @click="handleDelete(category)" />
<van-button
square
type="danger"
text="删除"
@click="handleDelete(category)"
/>
</template>
</van-swipe-cell>
</van-cell-group>
@@ -49,7 +78,12 @@
<div class="bottom-button">
<!-- 新增分类按钮 -->
<van-button type="primary" size="large" icon="plus" @click="handleAddCategory">
<van-button
type="primary"
size="large"
icon="plus"
@click="handleAddCategory"
>
新增分类
</van-button>
</div>

View File

@@ -1,6 +1,11 @@
<template>
<div class="page-container-flex classification-nlp">
<van-nav-bar title="自然语言分类" left-text="返回" left-arrow @click-left="onClickLeft" />
<van-nav-bar
title="自然语言分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content">
<!-- 输入区域 -->
@@ -18,18 +23,36 @@
</van-cell-group>
<div class="action-buttons">
<van-button type="primary" block round :loading="analyzing" @click="handleAnalyze">
<van-button
type="primary"
block
round
:loading="analyzing"
@click="handleAnalyze"
>
分析查询
</van-button>
</div>
</div>
<!-- 分析结果展示 -->
<div v-if="analysisResult" class="result-section">
<div
v-if="analysisResult"
class="result-section"
>
<van-cell-group inset>
<van-cell title="查询关键词" :value="analysisResult.searchKeyword" />
<van-cell title="AI建议类型" :value="getTypeName(analysisResult.targetType)" />
<van-cell title="AI建议分类" :value="analysisResult.targetClassify" />
<van-cell
title="查询关键词"
:value="analysisResult.searchKeyword"
/>
<van-cell
title="AI建议类型"
:value="getTypeName(analysisResult.targetType)"
/>
<van-cell
title="AI建议分类"
:value="analysisResult.targetClassify"
/>
<van-cell
title="找到记录"
:value="`${analysisResult.records.length} 条`"
@@ -48,12 +71,30 @@
/>
<!-- 记录列表弹窗 -->
<PopupContainer v-model="showRecordsList" title="交易记录列表" height="75%">
<PopupContainer
v-model="showRecordsList"
title="交易记录列表"
height="75%"
>
<div style="background: var(--van-background)">
<!-- 批量操作按钮 -->
<div class="batch-actions">
<van-button plain type="primary" size="small" @click="selectAll"> 全选 </van-button>
<van-button plain type="default" size="small" @click="selectNone"> 全不选 </van-button>
<van-button
plain
type="primary"
size="small"
@click="selectAll"
>
全选
</van-button>
<van-button
plain
type="default"
size="small"
@click="selectNone"
>
全不选
</van-button>
<van-button
type="success"
size="small"

View File

@@ -1,8 +1,16 @@
<template>
<div class="page-container-flex smart-classification">
<van-nav-bar title="智能分类" left-text="返回" left-arrow @click-left="onClickLeft" />
<van-nav-bar
title="智能分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content" style="padding-top: 5px">
<div
class="scroll-content"
style="padding-top: 5px"
>
<!-- 统计信息 -->
<div class="stats-info">
<span class="stats-label">未分类账单 </span>

View File

@@ -1,7 +1,9 @@
<template>
<div class="page-container login-container">
<div class="login-box">
<h1 class="login-title">账单</h1>
<h1 class="login-title">
账单
</h1>
<div class="login-form">
<van-field
v-model="password"

View File

@@ -12,7 +12,10 @@
</div>
<!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 -->
<van-loading
v-if="loading && !(transactionList && transactionList.length)"

View File

@@ -0,0 +1,58 @@
using FreeSql;
using Repository;
namespace WebApi.Test.Basic;
public class DatabaseTest : BaseTest, IDisposable
{
protected IFreeSql FreeSql { get; }
protected ITransactionRecordRepository Repository { get; }
public DatabaseTest()
{
FreeSql = new FreeSqlBuilder()
.UseConnectionString(DataType.Sqlite, "Data Source=:memory:")
.UseAutoSyncStructure(true)
.UseNoneCommandParameter(true)
.Build();
Repository = new TransactionRecordRepository(FreeSql);
}
public void Dispose()
{
FreeSql.Dispose();
}
protected TransactionRecord CreateTestRecord(
decimal amount,
TransactionType type = TransactionType.Expense,
DateTime? occurredAt = null,
string reason = "测试摘要",
string classify = "测试分类")
{
return new TransactionRecord
{
Amount = amount,
Type = type,
OccurredAt = occurredAt ?? DateTime.Now,
Reason = reason,
Classify = classify,
Card = "1234",
Balance = 1000,
EmailMessageId = 1,
ImportNo = Guid.NewGuid().ToString(),
ImportFrom = "测试"
};
}
protected TransactionRecord CreateExpense(decimal amount, DateTime? occurredAt = null, string reason = "支出", string classify = "餐饮")
{
return CreateTestRecord(-amount, TransactionType.Expense, occurredAt, reason, classify);
}
protected TransactionRecord CreateIncome(decimal amount, DateTime? occurredAt = null, string reason = "收入", string classify = "工资")
{
return CreateTestRecord(amount, TransactionType.Income, occurredAt, reason, classify);
}
}

View File

@@ -689,52 +689,7 @@ public class TransactionRecordController(
}
}
/// <summary>
/// 获取抵账候选列表
/// </summary>
[HttpGet("{id}")]
public async Task<BaseResponse<TransactionRecord[]>> GetCandidatesForOffset(long id)
{
try
{
var current = await transactionRepository.GetByIdAsync(id);
if (current == null)
{
return ((TransactionRecord[])[]).Ok();
}
var list = await transactionRepository.GetCandidatesForOffsetAsync(id, current.Amount, current.Type);
return list.ToArray().Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取抵账候选列表失败交易ID: {TransactionId}", id);
return $"获取抵账候选列表失败: {ex.Message}".Fail<TransactionRecord[]>();
}
}
/// <summary>
/// 抵账(删除两笔交易)
/// </summary>
[HttpPost]
public async Task<IActionResult> OffsetTransactions([FromBody] OffsetTransactionDto dto)
{
var t1 = await transactionRepository.GetByIdAsync(dto.Id1);
var t2 = await transactionRepository.GetByIdAsync(dto.Id2);
if (t1 == null || t2 == null)
{
return NotFound("交易记录不存在");
}
await transactionRepository.DeleteAsync(dto.Id1);
await transactionRepository.DeleteAsync(dto.Id2);
return Ok(new { success = true, message = "抵账成功" });
}
private async Task WriteEventAsync(string eventType, string data)
private async Task WriteEventAsync(string eventType, string data)
{
var message = $"event: {eventType}\ndata: {data}\n\n";
await Response.WriteAsync(message);
@@ -821,15 +776,7 @@ public record BatchUpdateByReasonDto(
/// </summary>
public record BillAnalysisRequest(
string UserInput
);
/// <summary>
/// 抵账请求DTO
/// </summary>
public record OffsetTransactionDto(
long Id1,
long Id2
);
);
public record ParseOneLineRequestDto(
string Text