feat: Refactor transaction handling and add new features
- Updated ReasonGroupList.vue to modify classify button behavior for adding new classifications. - Refactored TransactionDetail.vue to integrate PopupContainer and enhance transaction detail display. - Improved TransactionDetailDialog.vue with updated classify button functionality. - Simplified BalanceView.vue by removing manual entry button. - Enhanced PeriodicRecord.vue to update classify button interactions. - Removed unused add transaction dialog from TransactionsRecord.vue. - Added new API endpoints in TransactionRecordController for parsing transactions and handling offsets. - Introduced BillForm.vue and ManualBillAdd.vue for streamlined bill entry. - Implemented OneLineBillAdd.vue for intelligent transaction parsing. - Created GlobalAddBill.vue for a unified bill addition interface.
This commit is contained in:
@@ -169,6 +169,15 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <param name="limit">返回结果数量限制</param>
|
/// <param name="limit">返回结果数量限制</param>
|
||||||
/// <returns>带相关度分数的已分类账单列表</returns>
|
/// <returns>带相关度分数的已分类账单列表</returns>
|
||||||
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
@@ -636,6 +645,22 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
return scoredResults;
|
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 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)).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public interface ISmartHandleService
|
|||||||
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
|
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
|
||||||
|
|
||||||
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
|
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
|
||||||
|
|
||||||
|
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SmartHandleService(
|
public class SmartHandleService(
|
||||||
@@ -419,6 +421,41 @@ public class SmartHandleService(
|
|||||||
_ => "未知"
|
_ => "未知"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
|
||||||
|
{
|
||||||
|
// 获取所有分类
|
||||||
|
var categories = await categoryRepository.GetAllAsync();
|
||||||
|
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
||||||
|
|
||||||
|
var sysPrompt = $"""
|
||||||
|
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
|
||||||
|
|
||||||
|
请返回 JSON 格式,包含以下字段:
|
||||||
|
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss}。
|
||||||
|
- Amount: 金额,数字。
|
||||||
|
- Reason: 备注/摘要,原文或其他补充信息。
|
||||||
|
- Type: 交易类型,0=支出,1=收入,2=不计入收支。根据语义判断。
|
||||||
|
- Classify: 分类,请从以下现有分类中选择最匹配的一个:{categoryList}。如果无法匹配,请返回""其他""。
|
||||||
|
|
||||||
|
只返回 JSON,不要包含 markdown 标记。
|
||||||
|
""";
|
||||||
|
var json = await openAiService.ChatAsync(sysPrompt, text);
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 清理可能的 markdown 标记
|
||||||
|
json = json.Replace("```json", "").Replace("```", "").Trim();
|
||||||
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
return JsonSerializer.Deserialize<TransactionParseResult>(json, options);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "解析账单失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -435,3 +472,5 @@ public record GroupClassifyResult
|
|||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public TransactionType Type { get; set; }
|
public TransactionType Type { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vant": "^4.9.22",
|
"vant": "^4.9.22",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
8
Web/pnpm-lock.yaml
generated
8
Web/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.2
|
specifier: ^1.13.2
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.19
|
||||||
|
version: 1.11.19
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(vue@3.5.26)
|
version: 3.0.4(vue@3.5.26)
|
||||||
@@ -749,6 +752,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
dayjs@1.11.19:
|
||||||
|
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2100,6 +2106,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
dayjs@1.11.19: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
设置
|
设置
|
||||||
</van-tabbar-item>
|
</van-tabbar-item>
|
||||||
</van-tabbar>
|
</van-tabbar>
|
||||||
|
<GlobalAddBill @success="handleAddTransactionSuccess"/>
|
||||||
</div>
|
</div>
|
||||||
</van-config-provider>
|
</van-config-provider>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import GlobalAddBill from '@/components/Global/GlobalAddBill.vue'
|
||||||
import '@/styles/common.css'
|
import '@/styles/common.css'
|
||||||
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
@@ -119,6 +121,12 @@ const handleTabClick = (path) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddTransactionSuccess = () => {
|
||||||
|
// 当添加交易成功时,通知当前页面刷新数据
|
||||||
|
const event = new Event('transactions-changed')
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -200,3 +200,42 @@ export const nlpAnalysis = (userInput) => {
|
|||||||
data: { userInput }
|
data: { 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 - 用户输入的自然语言文本
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const parseOneLine = (text) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/ParseOneLine',
|
||||||
|
method: 'post',
|
||||||
|
data: { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
367
Web/src/components/Bill/BillForm.vue
Normal file
367
Web/src/components/Bill/BillForm.vue
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bill-form">
|
||||||
|
<van-form @submit="handleSubmit">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<!-- 日期时间 -->
|
||||||
|
<van-field label="时间">
|
||||||
|
<template #input>
|
||||||
|
<div style="display: flex; gap: 16px">
|
||||||
|
<div @click="showDatePicker = true">{{ form.date }}</div>
|
||||||
|
<div @click="showTimePicker = true">{{ form.time }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 金额 -->
|
||||||
|
<van-field
|
||||||
|
v-model="form.amount"
|
||||||
|
name="amount"
|
||||||
|
label="金额"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
:rules="[{ required: true, message: '请输入金额' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 备注 -->
|
||||||
|
<van-field
|
||||||
|
v-model="form.note"
|
||||||
|
name="note"
|
||||||
|
label="摘要"
|
||||||
|
placeholder="摘要信息"
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 交易类型 -->
|
||||||
|
<van-field name="type" label="类型">
|
||||||
|
<template #input>
|
||||||
|
<van-radio-group v-model="form.type" direction="horizontal">
|
||||||
|
<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="分类">
|
||||||
|
<template #input>
|
||||||
|
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
|
||||||
|
<span v-else>{{ categoryName }}</span>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 分类按钮网格 -->
|
||||||
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
v-for="item in categoryList"
|
||||||
|
:key="item.id"
|
||||||
|
:type="categoryName === item.name ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="selectClassify(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<van-button round block type="primary" native-type="submit" :loading="loading">
|
||||||
|
{{ submitText }}
|
||||||
|
</van-button>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</van-form>
|
||||||
|
|
||||||
|
<!-- 新增分类对话框 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showAddClassify"
|
||||||
|
title="新增交易分类"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="addNewClassify"
|
||||||
|
>
|
||||||
|
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||||
|
</van-dialog>
|
||||||
|
|
||||||
|
<!-- 日期选择弹窗 -->
|
||||||
|
<van-popup v-model:show="showDatePicker" position="bottom" round>
|
||||||
|
<van-date-picker
|
||||||
|
v-model="currentDate"
|
||||||
|
title="选择日期"
|
||||||
|
@confirm="onConfirmDate"
|
||||||
|
@cancel="showDatePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 时间选择弹窗 -->
|
||||||
|
<van-popup v-model:show="showTimePicker" position="bottom" round>
|
||||||
|
<van-time-picker
|
||||||
|
v-model="currentTime"
|
||||||
|
title="选择时间"
|
||||||
|
@confirm="onConfirmTime"
|
||||||
|
@cancel="showTimePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, toRefs } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
type: String,
|
||||||
|
default: '保存'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = ref({
|
||||||
|
type: 0, // 0: 支出, 1: 收入, 2: 不计
|
||||||
|
amount: '',
|
||||||
|
categoryId: null,
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
time: dayjs().format('HH:mm'),
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryName = ref('')
|
||||||
|
|
||||||
|
// 弹窗控制
|
||||||
|
const showDatePicker = ref(false)
|
||||||
|
const showTimePicker = ref(false)
|
||||||
|
const showAddClassify = ref(false)
|
||||||
|
const newClassify = ref('')
|
||||||
|
|
||||||
|
// 选择器数据
|
||||||
|
const categoryList = ref([])
|
||||||
|
|
||||||
|
// 日期时间临时变量 (Vant DatePicker 需要数组或特定格式)
|
||||||
|
const currentDate = ref(dayjs().format('YYYY-MM-DD').split('-'))
|
||||||
|
const currentTime = ref(dayjs().format('HH:mm').split(':'))
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
const initForm = async () => {
|
||||||
|
if (props.initialData) {
|
||||||
|
const { occurredAt, amount, reason, type, classify } = props.initialData
|
||||||
|
|
||||||
|
if (occurredAt) {
|
||||||
|
const dt = dayjs(occurredAt)
|
||||||
|
form.value.date = dt.format('YYYY-MM-DD')
|
||||||
|
form.value.time = dt.format('HH:mm')
|
||||||
|
currentDate.value = form.value.date.split('-')
|
||||||
|
currentTime.value = form.value.time.split(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount !== undefined) form.value.amount = amount
|
||||||
|
if (reason !== undefined) form.value.note = reason
|
||||||
|
if (type !== undefined) form.value.type = type
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
await loadClassifyList(form.value.type)
|
||||||
|
|
||||||
|
// 如果有传入分类名称,尝试匹配
|
||||||
|
if (classify) {
|
||||||
|
const found = categoryList.value.find(c => c.name === classify)
|
||||||
|
if (found) {
|
||||||
|
selectClassify(found)
|
||||||
|
} else {
|
||||||
|
// 如果没找到对应分类,但有分类名称,可能需要特殊处理或者就显示名称但不关联ID?
|
||||||
|
// 这里暂时只显示名称,ID为空,或者需要自动创建?
|
||||||
|
// 按照原有逻辑,后端需要分类名称,所以这里只要设置 categoryName 即可
|
||||||
|
// 但是 ManualBillAdd 原逻辑是需要 categoryId 的。
|
||||||
|
// 不过 createTransaction 接口传的是 classify (name)。
|
||||||
|
// 让我们看 ManualBillAdd 的 handleSave:
|
||||||
|
// classify: categoryName.value
|
||||||
|
// 所以只要 categoryName 有值就行。
|
||||||
|
categoryName.value = classify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await loadClassifyList(form.value.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initForm()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 initialData 变化 (例如重新解析后)
|
||||||
|
watch(() => props.initialData, () => {
|
||||||
|
initForm()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听交易类型变化,重新加载分类
|
||||||
|
watch(() => form.value.type, (newVal) => {
|
||||||
|
// 如果是初始化过程中导致的类型变化,可能不需要清空分类(如果已经匹配好了)
|
||||||
|
// 但如果是用户手动切换,应该清空
|
||||||
|
// 这里简单处理:如果是用户切换,通常需要重新选择。
|
||||||
|
// 为了避免初始化时的冲突,可以在 initForm 里处理好。
|
||||||
|
// 这里主要响应用户操作。
|
||||||
|
|
||||||
|
// 只有当当前分类不属于新类型时才清空?或者总是清空?
|
||||||
|
// 原逻辑是总是清空。
|
||||||
|
// 但是如果是 initForm 引起的,我们不希望清空。
|
||||||
|
// 暂时保持原逻辑,但在 initForm 里调用 loadClassifyList 后再设置 categoryName
|
||||||
|
|
||||||
|
// 简单的做法:在 watch 内部判断是否正在初始化?比较麻烦。
|
||||||
|
// 或者:只在用户点击 radio 时触发?
|
||||||
|
// watch 是最稳妥的,但要注意 initForm 里的顺序。
|
||||||
|
// 在 initForm 里,我们先设置 type,这会触发 watch。
|
||||||
|
// 所以 initForm 里的 loadClassifyList 可能会被 watch 里的覆盖。
|
||||||
|
// 让我们调整一下策略:
|
||||||
|
// 不在 watch 里加载,而是由 radio change 事件触发?
|
||||||
|
// 或者在 watch 里判断,如果 categoryName 已经有值且符合当前类型(这个很难判断),就不清空。
|
||||||
|
|
||||||
|
// 实际上,initForm 里设置 type 后,watch 会执行。
|
||||||
|
// watch 会清空 categoryName。
|
||||||
|
// 所以 initForm 里设置 type 后,再设置 categoryName 是没用的,除非 watch 是异步的或者我们在 nextTick 设置。
|
||||||
|
|
||||||
|
// 让我们修改 watch 逻辑,或者在 initForm 里处理。
|
||||||
|
// 更好的方式:
|
||||||
|
// 移除 watch,改为在 radio group 上 @change="handleTypeChange"
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTypeChange = (newType) => {
|
||||||
|
categoryName.value = ''
|
||||||
|
form.value.categoryId = null
|
||||||
|
loadClassifyList(newType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadClassifyList = async (type = null) => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoryList(type)
|
||||||
|
if (response.success) {
|
||||||
|
categoryList.value = response.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClassify = (item) => {
|
||||||
|
categoryName.value = item.name
|
||||||
|
form.value.categoryId = item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNewClassify = async () => {
|
||||||
|
if (!newClassify.value.trim()) {
|
||||||
|
showToast('请输入分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = newClassify.value.trim()
|
||||||
|
|
||||||
|
// 调用API创建分类
|
||||||
|
const response = await createCategory({
|
||||||
|
name: name,
|
||||||
|
type: form.value.type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('分类创建成功')
|
||||||
|
const newId = response.data
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList(form.value.type)
|
||||||
|
|
||||||
|
// 选中新创建的分类
|
||||||
|
categoryName.value = name
|
||||||
|
form.value.categoryId = newId
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '创建分类失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建分类出错:', error)
|
||||||
|
showToast('创建分类失败')
|
||||||
|
} finally {
|
||||||
|
newClassify.value = ''
|
||||||
|
showAddClassify.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirmDate = ({ selectedValues }) => {
|
||||||
|
form.value.date = selectedValues.join('-')
|
||||||
|
showDatePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirmTime = ({ selectedValues }) => {
|
||||||
|
form.value.time = selectedValues.join(':')
|
||||||
|
showTimePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.value.amount) {
|
||||||
|
showToast('请输入金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!categoryName.value) {
|
||||||
|
showToast('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDateTime = `${form.value.date}T${form.value.time}:00`
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
occurredAt: fullDateTime,
|
||||||
|
classify: categoryName.value,
|
||||||
|
amount: parseFloat(form.value.amount),
|
||||||
|
reason: form.value.note || '',
|
||||||
|
type: form.value.type
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露重置方法给父组件
|
||||||
|
const reset = () => {
|
||||||
|
form.value.amount = ''
|
||||||
|
form.value.note = ''
|
||||||
|
// 保留日期和类型
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bill-form {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin: 20px 16px;
|
||||||
|
}
|
||||||
|
.classify-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.classify-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 70px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="manual-bill-add">
|
||||||
|
<BillForm
|
||||||
|
ref="billFormRef"
|
||||||
|
:loading="saving"
|
||||||
|
@submit="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { createTransaction } from '@/api/transactionRecord'
|
||||||
|
import BillForm from './BillForm.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const billFormRef = ref(null)
|
||||||
|
|
||||||
|
const handleSave = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await createTransaction(payload)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('保存成功')
|
||||||
|
// 重置表单
|
||||||
|
if (billFormRef.value) {
|
||||||
|
billFormRef.value.reset()
|
||||||
|
}
|
||||||
|
emit('success')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('保存失败: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-bill-add {
|
||||||
|
/* padding-top: 10px; */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="input-section" v-if="!parseResult" style="margin: 12px 12px 0 16px;">
|
||||||
|
<van-field
|
||||||
|
v-model="text"
|
||||||
|
type="textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="例如:1月3日 晚餐 45.5 美团"
|
||||||
|
class="bill-input"
|
||||||
|
:disabled="parsing || saving"
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
round
|
||||||
|
block
|
||||||
|
@click="handleParse"
|
||||||
|
:loading="parsing"
|
||||||
|
:disabled="!text.trim()"
|
||||||
|
>
|
||||||
|
智能解析
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="parseResult" class="result-section">
|
||||||
|
<BillForm
|
||||||
|
:initial-data="parseResult"
|
||||||
|
:loading="saving"
|
||||||
|
submit-text="确认保存"
|
||||||
|
@submit="handleSave"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
block
|
||||||
|
@click="parseResult = null"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
重新输入
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</BillForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import BillForm from './BillForm.vue'
|
||||||
|
import { createTransaction, parseOneLine } from '@/api/transactionRecord'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const parsing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const parseResult = ref(null)
|
||||||
|
|
||||||
|
const handleParse = async () => {
|
||||||
|
if (!text.value.trim()) return
|
||||||
|
|
||||||
|
parsing.value = true
|
||||||
|
parseResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await parseOneLine(text.value)
|
||||||
|
if(!res.success){
|
||||||
|
throw new Error(res.message || '解析失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult.value = res.data
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('解析失败:' + err.message)
|
||||||
|
} finally {
|
||||||
|
parsing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await createTransaction(payload)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('保存成功')
|
||||||
|
text.value = ''
|
||||||
|
parseResult.value = null
|
||||||
|
emit('success')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
showToast('保存失败:' + err.message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bill-input {
|
||||||
|
background-color: var(--van-background-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ebedf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
Web/src/components/Global/GlobalAddBill.vue
Normal file
80
Web/src/components/Global/GlobalAddBill.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="global-add-bill">
|
||||||
|
<!-- Floating Add Bill Button -->
|
||||||
|
<div class="floating-add" @click="openAddBill">
|
||||||
|
<van-icon name="plus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Bill Modal -->
|
||||||
|
<PopupContainer
|
||||||
|
v-model="showAddBill"
|
||||||
|
title="记一笔"
|
||||||
|
height="85%"
|
||||||
|
>
|
||||||
|
<van-tabs v-model:active="activeTab" shrink>
|
||||||
|
<van-tab title="一句话录账" name="one">
|
||||||
|
<OneLineBillAdd @success="handleSuccess" />
|
||||||
|
</van-tab>
|
||||||
|
<van-tab title="手动录账" name="manual">
|
||||||
|
<ManualBillAdd @success="handleSuccess" />
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
</PopupContainer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineEmits } from 'vue'
|
||||||
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
|
||||||
|
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const showAddBill = ref(false)
|
||||||
|
const activeTab = ref('one')
|
||||||
|
|
||||||
|
const openAddBill = () => {
|
||||||
|
showAddBill.value = true
|
||||||
|
// Reset to default tab if needed, or keep last used
|
||||||
|
// activeTab.value = 'one'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
showAddBill.value = false
|
||||||
|
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-add {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px; /* Above tabbar */
|
||||||
|
right: 20px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background-color: var(--van-primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-add:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-tabs__wrap) {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,13 +9,20 @@
|
|||||||
<div class="popup-container">
|
<div class="popup-container">
|
||||||
<!-- 头部区域 -->
|
<!-- 头部区域 -->
|
||||||
<div class="popup-header-fixed">
|
<div class="popup-header-fixed">
|
||||||
<h3 class="popup-title">{{ title }}</h3>
|
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||||
|
<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">
|
||||||
|
<slot name="header-actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 子标题/统计信息 -->
|
<!-- 子标题/统计信息 -->
|
||||||
<div v-if="subtitle || hasActions" class="header-stats">
|
<div v-if="subtitle" class="header-stats">
|
||||||
<span v-if="subtitle" class="stats-text" v-html="subtitle" />
|
<span class="stats-text" v-html="subtitle" />
|
||||||
<!-- 额外操作插槽 -->
|
<!-- 额外操作插槽 -->
|
||||||
<slot name="header-actions"></slot>
|
<slot v-if="hasActions" name="header-actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,6 +91,24 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-title-row.has-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-row.has-actions .popup-title {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions-inline {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -134,6 +134,14 @@
|
|||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类按钮网格 -->
|
||||||
<div class="classify-buttons">
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
v-for="item in classifyOptions"
|
v-for="item in classifyOptions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -144,14 +152,6 @@
|
|||||||
>
|
>
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="batchForm.classify"
|
v-if="batchForm.classify"
|
||||||
type="warning"
|
type="warning"
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<van-popup
|
<PopupContainer
|
||||||
v-model:show="visible"
|
v-model="visible"
|
||||||
position="bottom"
|
title="交易详情"
|
||||||
:style="{ height: '85%' }"
|
height="85%"
|
||||||
round
|
:closeable="false"
|
||||||
closeable
|
|
||||||
@update:show="handleVisibleChange"
|
|
||||||
>
|
>
|
||||||
<div class="popup-container" v-if="transaction">
|
<template #header-actions>
|
||||||
<div class="popup-header-fixed">
|
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
|
||||||
<h3>交易详情</h3>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="popup-scroll-content">
|
<div v-if="transaction">
|
||||||
<van-form @submit="onSubmit" style="margin-top: 12px;">
|
<van-form @submit="onSubmit" style="margin-top: 12px;">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="卡号" :value="transaction.card" />
|
<van-cell title="卡号" :value="transaction.card" />
|
||||||
@@ -67,6 +64,14 @@
|
|||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类按钮网格 -->
|
||||||
<div class="classify-buttons">
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
v-for="item in classifyColumns"
|
v-for="item in classifyColumns"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -77,14 +82,6 @@
|
|||||||
>
|
>
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="editForm.classify"
|
v-if="editForm.classify"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -103,9 +100,8 @@
|
|||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</van-form>
|
</van-form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 交易类型选择器 -->
|
<!-- 交易类型选择器 -->
|
||||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||||
@@ -126,12 +122,33 @@
|
|||||||
>
|
>
|
||||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||||
</van-dialog>
|
</van-dialog>
|
||||||
|
|
||||||
|
<!-- 抵账候选列表弹窗 -->
|
||||||
|
<PopupContainer
|
||||||
|
v-model="showOffsetPopup"
|
||||||
|
title="选择抵账交易"
|
||||||
|
height="70%"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
||||||
import { showToast } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
import { updateTransaction } from '@/api/transactionRecord'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
||||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -207,10 +224,6 @@ watch(() => editForm.type, (newVal) => {
|
|||||||
loadClassifyList(newVal)
|
loadClassifyList(newVal)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleVisibleChange = (newVal) => {
|
|
||||||
emit('update:show', newVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载分类列表
|
// 加载分类列表
|
||||||
const loadClassifyList = async (type = null) => {
|
const loadClassifyList = async (type = null) => {
|
||||||
try {
|
try {
|
||||||
@@ -332,6 +345,51 @@ const formatDate = (dateString) => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 抵账相关
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@
|
|||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类按钮网格 -->
|
||||||
<div class="classify-buttons">
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
v-for="item in classifyOptions"
|
v-for="item in classifyOptions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -90,14 +98,6 @@
|
|||||||
>
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="formData.classify"
|
v-if="formData.classify"
|
||||||
type="warning"
|
type="warning"
|
||||||
|
|||||||
@@ -3,14 +3,6 @@
|
|||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<van-nav-bar title="交易记录" placeholder>
|
<van-nav-bar title="交易记录" placeholder>
|
||||||
<template #right>
|
<template #right>
|
||||||
<van-button
|
|
||||||
v-if="tabActive === 'balance'"
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="transactionsRecordRef.openAddDialog()"
|
|
||||||
>
|
|
||||||
手动录账
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="tabActive === 'email'"
|
v-if="tabActive === 'email'"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -191,6 +191,14 @@
|
|||||||
|
|
||||||
<!-- 分类按钮网格 -->
|
<!-- 分类按钮网格 -->
|
||||||
<div class="classify-buttons">
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
v-for="item in classifyColumns"
|
v-for="item in classifyColumns"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -201,14 +209,6 @@
|
|||||||
>
|
>
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
class="classify-btn"
|
|
||||||
@click="showAddClassify = true"
|
|
||||||
>
|
|
||||||
+ 新增
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
v-if="form.classify"
|
v-if="form.classify"
|
||||||
type="warning"
|
type="warning"
|
||||||
|
|||||||
@@ -32,124 +32,9 @@
|
|||||||
@save="onDetailSave"
|
@save="onDetailSave"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 新增交易记录弹出层 -->
|
|
||||||
<PopupContainer
|
|
||||||
v-model="addDialogVisible"
|
|
||||||
title="手动录账单"
|
|
||||||
height="85%"
|
|
||||||
>
|
|
||||||
<van-form @submit="onAddSubmit">
|
|
||||||
<van-cell-group inset title="基本信息">
|
|
||||||
<van-field
|
|
||||||
v-model="addForm.occurredAt"
|
|
||||||
is-link
|
|
||||||
readonly
|
|
||||||
name="occurredAt"
|
|
||||||
label="交易时间"
|
|
||||||
placeholder="请选择交易时间"
|
|
||||||
@click="showDateTimePicker = true"
|
|
||||||
:rules="[{ required: true, message: '请选择交易时间' }]"
|
|
||||||
/>
|
|
||||||
</van-cell-group>
|
|
||||||
|
|
||||||
<van-cell-group inset title="交易明细">
|
|
||||||
<van-field
|
|
||||||
v-model="addForm.reason"
|
|
||||||
name="reason"
|
|
||||||
label="交易摘要"
|
|
||||||
placeholder="请输入交易摘要"
|
|
||||||
type="textarea"
|
|
||||||
rows="2"
|
|
||||||
autosize
|
|
||||||
maxlength="200"
|
|
||||||
show-word-limit
|
|
||||||
/>
|
|
||||||
<van-field
|
|
||||||
v-model="addForm.amount"
|
|
||||||
name="amount"
|
|
||||||
label="交易金额"
|
|
||||||
placeholder="请输入交易金额"
|
|
||||||
type="number"
|
|
||||||
:rules="[{ required: true, message: '请输入交易金额' }]"
|
|
||||||
/>
|
|
||||||
<van-field
|
|
||||||
v-model="addForm.typeText"
|
|
||||||
is-link
|
|
||||||
readonly
|
|
||||||
name="type"
|
|
||||||
label="交易类型"
|
|
||||||
placeholder="请选择交易类型"
|
|
||||||
@click="showAddTypePicker = true"
|
|
||||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
|
||||||
/>
|
|
||||||
<van-field
|
|
||||||
v-model="addForm.classify"
|
|
||||||
is-link
|
|
||||||
readonly
|
|
||||||
name="classify"
|
|
||||||
label="交易分类"
|
|
||||||
placeholder="请选择或输入交易分类"
|
|
||||||
@click="showAddClassifyPicker = true"
|
|
||||||
/>
|
|
||||||
</van-cell-group>
|
|
||||||
|
|
||||||
<div style="margin: 16px;">
|
|
||||||
<van-button round block type="primary" native-type="submit" :loading="addSubmitting">
|
|
||||||
确认添加
|
|
||||||
</van-button>
|
|
||||||
</div>
|
|
||||||
</van-form>
|
|
||||||
</PopupContainer>
|
|
||||||
|
|
||||||
<!-- 新增交易 - 日期时间选择器 -->
|
|
||||||
<van-popup v-model:show="showDateTimePicker" position="bottom" round>
|
|
||||||
<van-date-picker
|
|
||||||
v-model="dateTimeValue"
|
|
||||||
title="选择日期时间"
|
|
||||||
:min-date="new Date(2020, 0, 1)"
|
|
||||||
:max-date="new Date()"
|
|
||||||
@confirm="onDateTimeConfirm"
|
|
||||||
@cancel="showDateTimePicker = false"
|
|
||||||
/>
|
|
||||||
</van-popup>
|
|
||||||
|
|
||||||
<!-- 新增交易 - 交易类型选择器 -->
|
|
||||||
<van-popup v-model:show="showAddTypePicker" position="bottom" round>
|
|
||||||
<van-picker
|
|
||||||
show-toolbar
|
|
||||||
:columns="typeColumns"
|
|
||||||
@confirm="onAddTypeConfirm"
|
|
||||||
@cancel="showAddTypePicker = false"
|
|
||||||
/>
|
|
||||||
</van-popup>
|
|
||||||
|
|
||||||
<!-- 新增交易 - 交易分类选择器 -->
|
|
||||||
<van-popup v-model:show="showAddClassifyPicker" position="bottom" round>
|
|
||||||
<van-picker
|
|
||||||
ref="addClassifyPickerRef"
|
|
||||||
:columns="classifyColumns"
|
|
||||||
@confirm="onAddClassifyConfirm"
|
|
||||||
@cancel="showAddClassifyPicker = false"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<div class="picker-toolbar">
|
|
||||||
<van-button class="toolbar-cancel" size="small" @click="clearAddClassify">清空</van-button>
|
|
||||||
<van-button class="toolbar-add" size="small" type="primary" @click="showAddClassify = true">新增</van-button>
|
|
||||||
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmAddClassify">确认</van-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</van-picker>
|
|
||||||
</van-popup>
|
|
||||||
|
|
||||||
<!-- 新增分类对话框 -->
|
|
||||||
<van-dialog
|
|
||||||
v-model:show="showAddClassify"
|
|
||||||
title="新增交易分类"
|
|
||||||
show-cancel-button
|
|
||||||
@confirm="addNewClassify"
|
|
||||||
>
|
|
||||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
|
||||||
</van-dialog>
|
|
||||||
|
|
||||||
<!-- 底部浮动搜索框 -->
|
<!-- 底部浮动搜索框 -->
|
||||||
<div class="floating-search">
|
<div class="floating-search">
|
||||||
@@ -165,18 +50,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import {
|
import {
|
||||||
getTransactionList,
|
getTransactionList,
|
||||||
getTransactionDetail,
|
getTransactionDetail
|
||||||
createTransaction,
|
|
||||||
deleteTransaction
|
|
||||||
} from '@/api/transactionRecord'
|
} from '@/api/transactionRecord'
|
||||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
|
||||||
import TransactionList from '@/components/TransactionList.vue'
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
import TransactionDetail from '@/components/TransactionDetail.vue'
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
import PopupContainer from '@/components/PopupContainer.vue'
|
|
||||||
|
|
||||||
const transactionList = ref([])
|
const transactionList = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -192,52 +73,7 @@ const currentTransaction = ref(null)
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
let searchTimer = null
|
let searchTimer = null
|
||||||
|
|
||||||
// 新增交易弹窗相关
|
|
||||||
const addDialogVisible = ref(false)
|
|
||||||
const addSubmitting = ref(false)
|
|
||||||
const showDateTimePicker = ref(false)
|
|
||||||
const dateTimeValue = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
|
|
||||||
const showAddTypePicker = ref(false)
|
|
||||||
const showAddClassifyPicker = ref(false)
|
|
||||||
const addClassifyPickerRef = ref(null)
|
|
||||||
const showAddClassify = ref(false)
|
|
||||||
const newClassify = ref('')
|
|
||||||
|
|
||||||
// 交易类型
|
|
||||||
const typeColumns = [
|
|
||||||
{ text: '支出', value: 0 },
|
|
||||||
{ text: '收入', value: 1 },
|
|
||||||
{ text: '不计入收支', value: 2 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 分类相关
|
|
||||||
const classifyColumns = ref([])
|
|
||||||
|
|
||||||
// 新增表单
|
|
||||||
const addForm = reactive({
|
|
||||||
occurredAt: '',
|
|
||||||
reason: '',
|
|
||||||
amount: '',
|
|
||||||
type: 0,
|
|
||||||
typeText: '',
|
|
||||||
classify: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 加载分类列表
|
|
||||||
const loadClassifyList = async (type = null) => {
|
|
||||||
try {
|
|
||||||
const response = await getCategoryList(type)
|
|
||||||
if (response.success) {
|
|
||||||
classifyColumns.value = (response.data || []).map(item => ({
|
|
||||||
text: item.name,
|
|
||||||
value: item.name,
|
|
||||||
id: item.id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载分类列表出错:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const loadData = async (isRefresh = false) => {
|
const loadData = async (isRefresh = false) => {
|
||||||
@@ -345,149 +181,16 @@ const viewDetail = async (transaction) => {
|
|||||||
// 详情保存后的回调
|
// 详情保存后的回调
|
||||||
const onDetailSave = async () => {
|
const onDetailSave = async () => {
|
||||||
loadData(true)
|
loadData(true)
|
||||||
// 重新加载分类列表
|
|
||||||
await loadClassifyList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除功能由 TransactionList 组件内部处理,组件通过 :show-delete 启用
|
// 删除功能由 TransactionList 组件内部处理,组件通过 :show-delete 启用
|
||||||
|
|
||||||
// 打开新增弹窗
|
|
||||||
const openAddDialog = () => {
|
|
||||||
// 重置表单
|
|
||||||
addForm.occurredAt = ''
|
|
||||||
addForm.reason = ''
|
|
||||||
addForm.amount = ''
|
|
||||||
addForm.type = 0
|
|
||||||
addForm.typeText = ''
|
|
||||||
addForm.classify = ''
|
|
||||||
|
|
||||||
// 设置默认日期时间为当前时间
|
|
||||||
const now = new Date()
|
|
||||||
dateTimeValue.value = [now.getFullYear(), now.getMonth() + 1, now.getDate()]
|
|
||||||
addForm.occurredAt = formatDateForSubmit(now)
|
|
||||||
|
|
||||||
addDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期用于提交
|
|
||||||
const formatDateForSubmit = (date) => {
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日期时间选择确认
|
|
||||||
const onDateTimeConfirm = ({ selectedValues }) => {
|
|
||||||
const date = new Date(selectedValues[0], selectedValues[1] - 1, selectedValues[2])
|
|
||||||
addForm.occurredAt = formatDateForSubmit(date)
|
|
||||||
showDateTimePicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增交易 - 交易类型选择确认
|
|
||||||
const onAddTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
|
||||||
addForm.type = selectedValues[0]
|
|
||||||
addForm.typeText = selectedOptions[0].text
|
|
||||||
showAddTypePicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增交易 - 交易分类选择确认
|
|
||||||
const onAddClassifyConfirm = ({ selectedOptions }) => {
|
|
||||||
if (selectedOptions && selectedOptions[0]) {
|
|
||||||
addForm.classify = selectedOptions[0].text
|
|
||||||
}
|
|
||||||
showAddClassifyPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增交易 - 清空分类
|
|
||||||
const clearAddClassify = () => {
|
|
||||||
addForm.classify = ''
|
|
||||||
showAddClassifyPicker.value = false
|
|
||||||
showToast('已清空分类')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增交易 - 确认分类(从 picker 中获取选中值)
|
|
||||||
const confirmAddClassify = () => {
|
|
||||||
if (addClassifyPickerRef.value) {
|
|
||||||
const selectedValues = addClassifyPickerRef.value.getSelectedOptions()
|
|
||||||
if (selectedValues && selectedValues[0]) {
|
|
||||||
addForm.classify = selectedValues[0].text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showAddClassifyPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增分类
|
|
||||||
const addNewClassify = async () => {
|
|
||||||
if (!newClassify.value.trim()) {
|
|
||||||
showToast('请输入分类名称')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await createCategory({
|
|
||||||
name: newClassify.value.trim(),
|
|
||||||
type: addForm.type
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
showToast('新增分类成功')
|
|
||||||
newClassify.value = ''
|
|
||||||
// 重新加载分类列表
|
|
||||||
await loadClassifyList(addForm.type)
|
|
||||||
// 设置为新增的分类
|
|
||||||
addForm.classify = response.data.name
|
|
||||||
} else {
|
|
||||||
showToast(response.message || '新增分类失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('新增分类失败:', error)
|
|
||||||
showToast('新增分类失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交新增交易
|
|
||||||
const onAddSubmit = async () => {
|
|
||||||
try {
|
|
||||||
addSubmitting.value = true
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
occurredAt: addForm.occurredAt,
|
|
||||||
reason: addForm.reason,
|
|
||||||
amount: parseFloat(addForm.amount),
|
|
||||||
type: addForm.type,
|
|
||||||
classify: addForm.classify || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await createTransaction(data)
|
|
||||||
if (response.success) {
|
|
||||||
showToast('添加成功')
|
|
||||||
addDialogVisible.value = false
|
|
||||||
loadData(true)
|
|
||||||
try { window.dispatchEvent(new CustomEvent('transactions-changed', { detail: response.data })) } catch(e) {}
|
|
||||||
// 重新加载分类列表
|
|
||||||
await loadClassifyList()
|
|
||||||
} else {
|
|
||||||
showToast(response.message || '添加失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('添加出错:', error)
|
|
||||||
showToast('添加失败: ' + (error.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
addSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadClassifyList()
|
|
||||||
// 不需要手动调用 loadData,van-list 会自动触发 onLoad
|
// 不需要手动调用 loadData,van-list 会自动触发 onLoad
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听全局删除事件,保持页面一致性
|
// 监听全局删除事件,保持页面一致性
|
||||||
const onGlobalTransactionDeleted = (e) => {
|
const onGlobalTransactionDeleted = () => {
|
||||||
// 如果在此页面,重新刷新当前列表以保持数据一致
|
// 如果在此页面,重新刷新当前列表以保持数据一致
|
||||||
transactionList.value = []
|
transactionList.value = []
|
||||||
pageIndex.value = 1
|
pageIndex.value = 1
|
||||||
@@ -502,7 +205,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 外部新增/修改/批量更新时的刷新监听
|
// 外部新增/修改/批量更新时的刷新监听
|
||||||
const onGlobalTransactionsChanged = (e) => {
|
const onGlobalTransactionsChanged = () => {
|
||||||
transactionList.value = []
|
transactionList.value = []
|
||||||
pageIndex.value = 1
|
pageIndex.value = 1
|
||||||
finished.value = false
|
finished.value = false
|
||||||
@@ -515,10 +218,7 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露给父级方法调用
|
|
||||||
defineExpose({
|
|
||||||
openAddDialog
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -548,21 +248,7 @@ defineExpose({
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-toolbar {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-bottom: 1px solid #ebedf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-cancel {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-confirm {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设置页面容器背景色 */
|
/* 设置页面容器背景色 */
|
||||||
:deep(.van-nav-bar) {
|
:deep(.van-nav-bar) {
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ public class TransactionRecordController(
|
|||||||
Type = dto.Type,
|
Type = dto.Type,
|
||||||
Classify = dto.Classify ?? string.Empty,
|
Classify = dto.Classify ?? string.Empty,
|
||||||
ImportFrom = "手动录入",
|
ImportFrom = "手动录入",
|
||||||
|
ImportNo = Guid.NewGuid().ToString("N"),
|
||||||
|
Card = "手动",
|
||||||
EmailMessageId = 0 // 手动录入的记录,EmailMessageId 设为 0
|
EmailMessageId = 0 // 手动录入的记录,EmailMessageId 设为 0
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,6 +548,80 @@ public class TransactionRecordController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一句话录账解析
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<TransactionParseResult>> ParseOneLine([FromBody] ParseOneLineRequestDto request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(request.Text))
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionParseResult>.Fail("请求参数缺失:text");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await smartHandleService.ParseOneLineBillAsync(request.Text);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionParseResult>.Fail("AI解析失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseResponse<TransactionParseResult>.Done(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "一句话录账解析失败,文本: {Text}", request.Text);
|
||||||
|
return BaseResponse<TransactionParseResult>.Fail("AI解析失败: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取抵账候选列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<BaseResponse<TransactionRecord[]>> GetCandidatesForOffset(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var current = await transactionRepository.GetByIdAsync(id);
|
||||||
|
if (current == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionRecord[]>.Done([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await transactionRepository.GetCandidatesForOffsetAsync(id, current.Amount, current.Type);
|
||||||
|
|
||||||
|
return BaseResponse<TransactionRecord[]>.Done(list.ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取抵账候选列表失败,交易ID: {TransactionId}", id);
|
||||||
|
return BaseResponse<TransactionRecord[]>.Fail($"获取抵账候选列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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";
|
var message = $"event: {eventType}\ndata: {data}\n\n";
|
||||||
@@ -635,3 +711,16 @@ public record BatchUpdateByReasonDto(
|
|||||||
public record BillAnalysisRequest(
|
public record BillAnalysisRequest(
|
||||||
string UserInput
|
string UserInput
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 抵账请求DTO
|
||||||
|
/// </summary>
|
||||||
|
public record OffsetTransactionDto(
|
||||||
|
long Id1,
|
||||||
|
long Id2
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
public record ParseOneLineRequestDto(
|
||||||
|
string Text
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user