feat: 添加账单软删除功能
- 新增删除按钮(带二次确认)到账单详情抽屉 - 后端实现软删除(设置 is_deleted 标记) - 所有查询方法自动过滤已删除记录 - 账单列表和复核页面都支持删除 - 版本更新至 1.2.0
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,20 @@
|
|||||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-25
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **账单删除功能** - 支持在账单详情抽屉中删除账单(软删除)
|
||||||
|
- 删除按钮带二次确认,防止误操作
|
||||||
|
- 删除后数据标记为 `is_deleted`,不真正从数据库删除
|
||||||
|
- 已删除的账单在所有查询中自动过滤
|
||||||
|
- 账单列表和复核页面都支持删除操作
|
||||||
|
|
||||||
|
### 技术改进
|
||||||
|
- 后端 MongoDB 查询方法添加软删除过滤
|
||||||
|
- 新增 `DELETE /api/bills/:id` 接口
|
||||||
|
- `BillDetailDrawer` 组件新增 `allowDelete` 和 `onDelete` props
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-23
|
## [1.1.0] - 2026-01-23
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
42
server/handler/delete_bill.go
Normal file
42
server/handler/delete_bill.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"billai-server/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeleteBillResponse struct {
|
||||||
|
Result bool `json:"result"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
|
||||||
|
func DeleteBill(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, DeleteBillResponse{Result: false, Message: "缺少账单 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
if repo == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "数据库未连接"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.DeleteCleanedBillByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, DeleteBillResponse{Result: false, Message: "账单不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "删除失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, DeleteBillResponse{Result: true, Message: "删除成功"})
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type UpdateBillRequest struct {
|
|||||||
PayMethod *string `json:"pay_method,omitempty"`
|
PayMethod *string `json:"pay_method,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Remark *string `json:"remark,omitempty"`
|
Remark *string `json:"remark,omitempty"`
|
||||||
|
ReviewLevel *string `json:"review_level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateBillResponse struct {
|
type UpdateBillResponse struct {
|
||||||
@@ -119,6 +120,16 @@ func UpdateBill(c *gin.Context) {
|
|||||||
updates["remark"] = strings.TrimSpace(*req.Remark)
|
updates["remark"] = strings.TrimSpace(*req.Remark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ReviewLevel != nil {
|
||||||
|
// 允许设置为空字符串(清除复核等级)或 HIGH/LOW
|
||||||
|
v := strings.TrimSpace(*req.ReviewLevel)
|
||||||
|
if v != "" && v != "HIGH" && v != "LOW" {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "review_level 只能是空、HIGH 或 LOW"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["review_level"] = v
|
||||||
|
}
|
||||||
|
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ type CleanedBill struct {
|
|||||||
Status string `bson:"status" json:"status"` // 交易状态
|
Status string `bson:"status" json:"status"` // 交易状态
|
||||||
Remark string `bson:"remark" json:"remark"` // 备注
|
Remark string `bson:"remark" json:"remark"` // 备注
|
||||||
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
||||||
|
IsDeleted bool `bson:"is_deleted" json:"is_deleted"` // 是否已删除(软删除)
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
|
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
|
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
|
||||||
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ func (r *Repository) GetCleanedBills(filter map[string]interface{}) ([]model.Cle
|
|||||||
bsonFilter[k] = v
|
bsonFilter[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除已删除的记录
|
||||||
|
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||||
|
|
||||||
// 按时间倒序排列
|
// 按时间倒序排列
|
||||||
opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}})
|
opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}})
|
||||||
|
|
||||||
@@ -220,6 +223,9 @@ func (r *Repository) GetCleanedBillsPaged(filter map[string]interface{}, page, p
|
|||||||
bsonFilter[k] = v
|
bsonFilter[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除已删除的记录
|
||||||
|
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||||
|
|
||||||
// 计算总数
|
// 计算总数
|
||||||
total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter)
|
total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -260,6 +266,9 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
|
|||||||
bsonFilter[k] = v
|
bsonFilter[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除已删除的记录
|
||||||
|
bsonFilter["is_deleted"] = bson.M{"$ne": true}
|
||||||
|
|
||||||
// 使用聚合管道按 income_expense 分组统计金额
|
// 使用聚合管道按 income_expense 分组统计金额
|
||||||
pipeline := mongo.Pipeline{
|
pipeline := mongo.Pipeline{
|
||||||
{{Key: "$match", Value: bsonFilter}},
|
{{Key: "$match", Value: bsonFilter}},
|
||||||
@@ -300,6 +309,7 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
|
|||||||
func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
|
func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
|
||||||
filter := map[string]interface{}{
|
filter := map[string]interface{}{
|
||||||
"review_level": bson.M{"$in": []string{"HIGH", "LOW"}},
|
"review_level": bson.M{"$in": []string{"HIGH", "LOW"}},
|
||||||
|
"is_deleted": bson.M{"$ne": true},
|
||||||
}
|
}
|
||||||
return r.GetCleanedBills(filter)
|
return r.GetCleanedBills(filter)
|
||||||
}
|
}
|
||||||
@@ -312,6 +322,8 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
|
|||||||
// 使用聚合管道按月份分组统计
|
// 使用聚合管道按月份分组统计
|
||||||
// 先按月份和收支类型分组,再汇总
|
// 先按月份和收支类型分组,再汇总
|
||||||
pipeline := mongo.Pipeline{
|
pipeline := mongo.Pipeline{
|
||||||
|
// 排除已删除的记录
|
||||||
|
{{Key: "$match", Value: bson.M{"is_deleted": bson.M{"$ne": true}}}},
|
||||||
// 添加月份字段
|
// 添加月份字段
|
||||||
{{Key: "$addFields", Value: bson.D{
|
{{Key: "$addFields", Value: bson.D{
|
||||||
{Key: "month", Value: bson.D{
|
{Key: "month", Value: bson.D{
|
||||||
@@ -418,6 +430,34 @@ func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interfa
|
|||||||
return &updated, nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteCleanedBillByID 按 ID 软删除清洗后的账单(设置 is_deleted = true)
|
||||||
|
func (r *Repository) DeleteCleanedBillByID(id string) error {
|
||||||
|
if r.cleanedCollection == nil {
|
||||||
|
return fmt.Errorf("cleaned collection not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
oid, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": oid}
|
||||||
|
update := bson.M{"$set": bson.M{"is_deleted": true}}
|
||||||
|
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("soft delete bill failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return repository.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type BillRepository interface {
|
|||||||
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
||||||
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
|
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
|
||||||
|
|
||||||
|
// DeleteCleanedBillByID 按 ID 删除清洗后的账单
|
||||||
|
DeleteCleanedBillByID(id string) error
|
||||||
|
|
||||||
// CountRawByField 按字段统计原始数据数量
|
// CountRawByField 按字段统计原始数据数量
|
||||||
CountRawByField(fieldName, value string) (int64, error)
|
CountRawByField(fieldName, value string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
// 编辑账单
|
// 编辑账单
|
||||||
authed.POST("/bills/:id", handler.UpdateBill)
|
authed.POST("/bills/:id", handler.UpdateBill)
|
||||||
|
|
||||||
|
// 删除账单(软删除)
|
||||||
|
authed.DELETE("/bills/:id", handler.DeleteBill)
|
||||||
|
|
||||||
// 手动创建账单
|
// 手动创建账单
|
||||||
authed.POST("/bills/manual", handler.CreateManualBills)
|
authed.POST("/bills/manual", handler.CreateManualBills)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export interface UpdateBillRequest {
|
|||||||
pay_method?: string;
|
pay_method?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
review_level?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateBillResponse {
|
export interface UpdateBillResponse {
|
||||||
@@ -393,3 +394,22 @@ export async function fetchBillsByReviewLevel(): Promise<BillsResponse> {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除账单响应
|
||||||
|
export interface DeleteBillResponse {
|
||||||
|
result: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账单(软删除)
|
||||||
|
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
|
||||||
|
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
import Save from '@lucide/svelte/icons/save';
|
import Save from '@lucide/svelte/icons/save';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
import Calendar from '@lucide/svelte/icons/calendar';
|
import Calendar from '@lucide/svelte/icons/calendar';
|
||||||
import Store from '@lucide/svelte/icons/store';
|
import Store from '@lucide/svelte/icons/store';
|
||||||
import Tag from '@lucide/svelte/icons/tag';
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
import FileText from '@lucide/svelte/icons/file-text';
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||||
|
|
||||||
import { updateBill } from '$lib/api';
|
import { updateBill, deleteBill } from '$lib/api';
|
||||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,7 +35,14 @@
|
|||||||
|
|
||||||
contentClass?: string;
|
contentClass?: string;
|
||||||
|
|
||||||
|
/** 保存时是否清除 review_level(用于复核场景) */
|
||||||
|
clearReviewLevel?: boolean;
|
||||||
|
|
||||||
|
/** 是否允许删除 */
|
||||||
|
allowDelete?: boolean;
|
||||||
|
|
||||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||||
|
onDelete?: (deleted: UIBill) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -45,11 +54,17 @@
|
|||||||
editDescription = '修改这笔支出的信息',
|
editDescription = '修改这笔支出的信息',
|
||||||
titleExtra,
|
titleExtra,
|
||||||
contentClass,
|
contentClass,
|
||||||
onUpdate
|
clearReviewLevel = false,
|
||||||
|
allowDelete = false,
|
||||||
|
onUpdate,
|
||||||
|
onDelete
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
let isConfirming = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
let editForm = $state({
|
let editForm = $state({
|
||||||
amount: '',
|
amount: '',
|
||||||
@@ -62,6 +77,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
isEditing = false;
|
isEditing = false;
|
||||||
|
showDeleteConfirm = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
@@ -84,6 +100,52 @@
|
|||||||
if (value) editForm.category = value;
|
if (value) editForm.category = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确认正确(仅清除 review_level,不修改其他字段)
|
||||||
|
async function confirmCorrect() {
|
||||||
|
if (!record || isConfirming) return;
|
||||||
|
|
||||||
|
isConfirming = true;
|
||||||
|
const original = { ...record };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const billId = record.id;
|
||||||
|
if (billId) {
|
||||||
|
const resp = await updateBill(billId, { review_level: '' });
|
||||||
|
|
||||||
|
if (resp.result && resp.data) {
|
||||||
|
const updated = cleanedBillToUIBill(resp.data);
|
||||||
|
record = updated;
|
||||||
|
onUpdate?.(updated, original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isConfirming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账单
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!record || isDeleting) return;
|
||||||
|
|
||||||
|
isDeleting = true;
|
||||||
|
const deleted = { ...record };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const billId = record.id;
|
||||||
|
if (billId) {
|
||||||
|
const resp = await deleteBill(billId);
|
||||||
|
|
||||||
|
if (resp.result) {
|
||||||
|
open = false;
|
||||||
|
onDelete?.(deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isDeleting = false;
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
@@ -108,7 +170,9 @@
|
|||||||
category: editForm.category,
|
category: editForm.category,
|
||||||
amount: Number(editForm.amount),
|
amount: Number(editForm.amount),
|
||||||
description: editForm.description,
|
description: editForm.description,
|
||||||
pay_method: editForm.payment_method
|
pay_method: editForm.payment_method,
|
||||||
|
// 复核模式下清除 review_level
|
||||||
|
...(clearReviewLevel ? { review_level: '' } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.result && resp.data) {
|
if (resp.result && resp.data) {
|
||||||
@@ -199,8 +263,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">¥{record.amount.toFixed(2)}</div>
|
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
||||||
<div class="text-sm text-muted-foreground mt-1">支出金额</div>
|
¥{record.amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -263,10 +329,35 @@
|
|||||||
<Save class="h-4 w-4 mr-2" />
|
<Save class="h-4 w-4 mr-2" />
|
||||||
{isSaving ? '保存中…' : '保存'}
|
{isSaving ? '保存中…' : '保存'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{:else if showDeleteConfirm}
|
||||||
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
<span class="text-sm text-muted-foreground">确定要删除这条账单吗?</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Button variant="outline" onclick={() => (showDeleteConfirm = false)} disabled={isDeleting}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onclick={handleDelete} disabled={isDeleting}>
|
||||||
|
<Trash2 class="h-4 w-4 mr-2" />
|
||||||
|
{isDeleting ? '删除中…' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if allowDelete}
|
||||||
|
<Button variant="outline" class="text-red-600 hover:text-red-700 hover:bg-red-50" onclick={() => (showDeleteConfirm = true)}>
|
||||||
|
<Trash2 class="h-4 w-4 mr-2" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1"></div>
|
||||||
<Button variant="outline" onclick={() => (open = false)}>
|
<Button variant="outline" onclick={() => (open = false)}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
|
{#if clearReviewLevel}
|
||||||
|
<Button class="bg-green-600 hover:bg-green-700 text-white" onclick={confirmCorrect} disabled={isConfirming}>
|
||||||
|
<Check class="h-4 w-4 mr-2" />
|
||||||
|
{isConfirming ? '确认中…' : '确认正确'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<Button onclick={startEdit}>
|
<Button onclick={startEdit}>
|
||||||
<Pencil class="h-4 w-4 mr-2" />
|
<Pencil class="h-4 w-4 mr-2" />
|
||||||
编辑
|
编辑
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
||||||
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
||||||
|
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||||||
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
@@ -178,6 +180,51 @@
|
|||||||
// 重新加载账单列表(保持当前日期筛选)
|
// 重新加载账单列表(保持当前日期筛选)
|
||||||
loadBills();
|
loadBills();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 账单详情抽屉
|
||||||
|
let drawerOpen = $state(false);
|
||||||
|
let selectedBill = $state<UIBill | null>(null);
|
||||||
|
|
||||||
|
// 点击行打开详情
|
||||||
|
function handleRowClick(record: CleanedBill) {
|
||||||
|
selectedBill = cleanedBillToUIBill(record);
|
||||||
|
drawerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账单后刷新列表
|
||||||
|
function handleBillUpdate(updated: UIBill) {
|
||||||
|
// 在当前列表中更新对应的记录
|
||||||
|
const index = records.findIndex(r => r.id === updated.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
records[index] = {
|
||||||
|
...records[index],
|
||||||
|
time: updated.time,
|
||||||
|
category: updated.category,
|
||||||
|
merchant: updated.merchant,
|
||||||
|
description: updated.description || '',
|
||||||
|
income_expense: updated.incomeExpense,
|
||||||
|
amount: updated.amount,
|
||||||
|
pay_method: updated.paymentMethod || '',
|
||||||
|
status: updated.status || '',
|
||||||
|
remark: updated.remark || '',
|
||||||
|
review_level: updated.reviewLevel || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账单后刷新列表
|
||||||
|
function handleBillDelete(deleted: UIBill) {
|
||||||
|
// 从列表中移除对应的记录
|
||||||
|
records = records.filter(r => r.id !== deleted.id);
|
||||||
|
totalRecords = Math.max(0, totalRecords - 1);
|
||||||
|
|
||||||
|
// 更新聚合统计
|
||||||
|
if (deleted.incomeExpense === '支出') {
|
||||||
|
totalExpense = Math.max(0, totalExpense - deleted.amount);
|
||||||
|
} else if (deleted.incomeExpense === '收入') {
|
||||||
|
totalIncome = Math.max(0, totalIncome - deleted.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -383,7 +430,10 @@
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each displayRecords as record}
|
{#each displayRecords as record}
|
||||||
<Table.Row>
|
<Table.Row
|
||||||
|
class="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onclick={() => handleRowClick(record)}
|
||||||
|
>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{formatDateTime(record.time)}
|
{formatDateTime(record.time)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -486,3 +536,16 @@
|
|||||||
<ManualBillInput onSuccess={handleManualBillSuccess} />
|
<ManualBillInput onSuccess={handleManualBillSuccess} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 账单详情抽屉 -->
|
||||||
|
<BillDetailDrawer
|
||||||
|
bind:open={drawerOpen}
|
||||||
|
bind:record={selectedBill}
|
||||||
|
categories={categories}
|
||||||
|
title="账单详情"
|
||||||
|
viewDescription="查看这笔账单的完整信息"
|
||||||
|
editDescription="修改这笔账单的信息"
|
||||||
|
allowDelete={true}
|
||||||
|
onUpdate={handleBillUpdate}
|
||||||
|
onDelete={handleBillDelete}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte';
|
||||||
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
@@ -68,6 +70,64 @@
|
|||||||
let totalCount = $derived(reviewStats?.total || 0);
|
let totalCount = $derived(reviewStats?.total || 0);
|
||||||
let highCount = $derived(reviewStats?.high || 0);
|
let highCount = $derived(reviewStats?.high || 0);
|
||||||
let lowCount = $derived(reviewStats?.low || 0);
|
let lowCount = $derived(reviewStats?.low || 0);
|
||||||
|
|
||||||
|
// 账单详情抽屉
|
||||||
|
let drawerOpen = $state(false);
|
||||||
|
let selectedBill = $state<UIBill | null>(null);
|
||||||
|
|
||||||
|
// 分类列表(用于编辑选择)
|
||||||
|
const categories = [
|
||||||
|
'餐饮美食', '交通出行', '生活服务', '日用百货',
|
||||||
|
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
|
||||||
|
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 点击行打开详情
|
||||||
|
function handleRowClick(record: CleanedBill) {
|
||||||
|
selectedBill = cleanedBillToUIBill(record);
|
||||||
|
drawerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复核完成后从列表中移除该记录
|
||||||
|
function handleBillUpdate(updated: UIBill, original: UIBill) {
|
||||||
|
// 更新后 review_level 已被清除,从列表中移除
|
||||||
|
const index = allBills.findIndex(r => r.id === updated.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
allBills.splice(index, 1);
|
||||||
|
// 触发响应式更新
|
||||||
|
allBills = [...allBills];
|
||||||
|
}
|
||||||
|
// 更新统计数据(根据原始的 review_level 减少计数)
|
||||||
|
if (reviewStats) {
|
||||||
|
reviewStats = {
|
||||||
|
...reviewStats,
|
||||||
|
total: Math.max(0, reviewStats.total - 1),
|
||||||
|
high: original.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||||||
|
low: original.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 关闭抽屉
|
||||||
|
drawerOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账单后从列表中移除该记录
|
||||||
|
function handleBillDelete(deleted: UIBill) {
|
||||||
|
// 从列表中移除对应的记录
|
||||||
|
const index = allBills.findIndex(r => r.id === deleted.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
allBills.splice(index, 1);
|
||||||
|
allBills = [...allBills];
|
||||||
|
}
|
||||||
|
// 更新统计数据
|
||||||
|
if (reviewStats) {
|
||||||
|
reviewStats = {
|
||||||
|
...reviewStats,
|
||||||
|
total: Math.max(0, reviewStats.total - 1),
|
||||||
|
high: deleted.reviewLevel === 'HIGH' ? Math.max(0, reviewStats.high - 1) : reviewStats.high,
|
||||||
|
low: deleted.reviewLevel === 'LOW' ? Math.max(0, reviewStats.low - 1) : reviewStats.low
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -198,7 +258,10 @@
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each filteredRecords as record}
|
{#each filteredRecords as record}
|
||||||
<Table.Row>
|
<Table.Row
|
||||||
|
class="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onclick={() => handleRowClick(record)}
|
||||||
|
>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
|
{record.time ? new Date(record.time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -240,3 +303,17 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 账单详情抽屉(复核模式) -->
|
||||||
|
<BillDetailDrawer
|
||||||
|
bind:open={drawerOpen}
|
||||||
|
bind:record={selectedBill}
|
||||||
|
categories={categories}
|
||||||
|
title="复核账单"
|
||||||
|
viewDescription="确认或修改这笔账单的分类"
|
||||||
|
editDescription="修改这笔账单的分类信息"
|
||||||
|
clearReviewLevel={true}
|
||||||
|
allowDelete={true}
|
||||||
|
onUpdate={handleBillUpdate}
|
||||||
|
onDelete={handleBillDelete}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user