feat: refine reimbursements and add parenting categories

This commit is contained in:
openclaw 2026-08-08 00:32:06 +08:00
parent 18fa42c424
commit 99933da2c8
6 changed files with 105 additions and 10 deletions

View File

@ -2,6 +2,7 @@ import type { EntryType } from "@cents/domain";
import { import {
Ambulance, Ambulance,
Apple, Apple,
Baby,
Banknote, Banknote,
BatteryCharging, BatteryCharging,
Bike, Bike,
@ -34,6 +35,7 @@ import {
Hospital, Hospital,
Hotel, Hotel,
House, House,
Milk,
ParkingCircle, ParkingCircle,
Pencil, Pencil,
Pill, Pill,
@ -44,6 +46,7 @@ import {
ShoppingBag, ShoppingBag,
ShoppingBasket, ShoppingBasket,
Shirt, Shirt,
SoapDispenserDroplet,
Smartphone, Smartphone,
Soup, Soup,
Sparkles, Sparkles,
@ -55,6 +58,8 @@ import {
UsersRound, UsersRound,
WalletCards, WalletCards,
Zap, Zap,
CircleEllipsis,
PackageOpen,
type LucideIcon, type LucideIcon,
} from "@lucide/vue"; } from "@lucide/vue";
@ -130,6 +135,24 @@ export const categories: Record<EntryType, Category[]> = {
{ id: "toys", label: "玩具", color: "#7559d9", tint: "#f2efff", icon: Puzzle }, { id: "toys", label: "玩具", color: "#7559d9", tint: "#f2efff", icon: Puzzle },
], ],
}, },
{
id: "parenting",
label: "育儿",
color: "#de6b79",
tint: "#fff0f2",
icon: Baby,
children: [
{ id: "formula", label: "奶粉", color: "#c88945", tint: "#fff5e8", icon: Milk },
{ id: "maternal-baby", label: "母婴", color: "#de6b79", tint: "#fff0f2", icon: Baby },
{ id: "children-clothing", label: "童装", color: "#b14e79", tint: "#faeef4", icon: Shirt },
{ id: "children-toys", label: "玩具", color: "#7559d9", tint: "#f2efff", icon: Puzzle },
{ id: "children-tuition", label: "学费", color: "#4869b1", tint: "#edf2fb", icon: GraduationCap },
{ id: "children-supplements", label: "补剂", color: "#2f8768", tint: "#eaf7f1", icon: Pill },
{ id: "children-care", label: "洗护", color: "#2d8f9e", tint: "#eaf8fa", icon: SoapDispenserDroplet },
{ id: "children-paper", label: "纸品", color: "#7a8a86", tint: "#f0f4f2", icon: PackageOpen },
{ id: "children-other", label: "其他", color: "#526a78", tint: "#edf2f4", icon: CircleEllipsis },
],
},
{ {
id: "entertainment", id: "entertainment",
label: "娱乐", label: "娱乐",

View File

@ -696,6 +696,32 @@ input:focus-visible {
color: #0d8b67; color: #0d8b67;
} }
.entry-reimbursement-value {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.entry-value .entry-reimbursement-tag {
display: inline-block;
margin: 0;
padding: 1px 4px;
border: 1px solid #c5d6ea;
border-radius: 3px;
color: #5e7895;
font-size: 9px;
line-height: 1.25;
white-space: nowrap;
}
.entry-value .reimbursement {
color: #3478b8;
text-decoration: line-through;
text-decoration-thickness: 1px;
text-decoration-color: #78a5cc;
}
.empty-state { .empty-state {
min-height: 260px; min-height: 260px;
display: grid; display: grid;

View File

@ -20,7 +20,7 @@ import {
WalletCards, WalletCards,
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
categories, categories,
@ -151,11 +151,25 @@ const canRecordReimbursement = computed(() =>
onMounted(async () => { onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]); await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
const found = store.entries.find((item) => item.id === String(route.params.id)); hydrateCurrentEntry();
if (found) hydrate(found);
loading.value = false; loading.value = false;
}); });
watch(() => route.params.id, () => {
hydrateCurrentEntry();
});
function hydrateCurrentEntry() {
const found = store.entries.find((item) => item.id === String(route.params.id));
if (!found) return;
categorySheetMode.value = null;
ledgerPickerOpen.value = false;
currencyPickerOpen.value = false;
reimbursementOpen.value = false;
errorMessage.value = "";
hydrate(found);
}
function hydrate(value: LedgerEntry) { function hydrate(value: LedgerEntry) {
entry.value = value; entry.value = value;
currency.value = value.currency; currency.value = value.currency;

View File

@ -999,7 +999,11 @@ async function shareLedger() {
</div> </div>
<div class="entry-value"> <div class="entry-value">
<span v-if="entryHasReimbursement(entry)" class="entry-original-amount">{{ entryDisplayAmount(entry) }}</span> <span v-if="entryHasReimbursement(entry)" class="entry-original-amount">{{ entryDisplayAmount(entry) }}</span>
<strong :class="entry.type">{{ entry.type === "expense" ? "" : "+" }}{{ entryDisplayAmount(entry, true) }}</strong> <div v-if="isReimbursement(entry)" class="entry-reimbursement-value">
<span class="entry-reimbursement-tag">不计入</span>
<strong class="reimbursement">{{ entryDisplayAmount(entry, true) }}</strong>
</div>
<strong v-else :class="entry.type">{{ entry.type === "expense" ? "" : "+" }}{{ entryDisplayAmount(entry, true) }}</strong>
<span>{{ formatTime(entry.occurredAt) }}</span> <span>{{ formatTime(entry.occurredAt) }}</span>
</div> </div>
</RouterLink> </RouterLink>

View File

@ -17,18 +17,19 @@
### 支出 ### 支出
8 个。 9 个。
| 顺序 | 分类 | ID | | 顺序 | 分类 | ID |
| --- | --- | --- | | --- | --- | --- |
| 1 | 餐饮 | `food` | | 1 | 餐饮 | `food` |
| 2 | 交通 | `transport` | | 2 | 交通 | `transport` |
| 3 | 购物 | `shopping` | | 3 | 购物 | `shopping` |
| 4 | 娱乐 | `entertainment` | | 4 | 育儿 | `parenting` |
| 5 | 社交 | `social` | | 5 | 娱乐 | `entertainment` |
| 6 | 医疗 | `health` | | 6 | 社交 | `social` |
| 7 | 教育 | `education` | | 7 | 医疗 | `health` |
| 8 | 缴费 | `bills` | | 8 | 教育 | `education` |
| 9 | 缴费 | `bills` |
### 收入 ### 收入
@ -54,6 +55,7 @@
| 餐饮 | 外卖 `takeout`、堂食 `restaurant`、咖啡 `coffee`、零食 `snacks`、饮品 `drinks`、水果 `fruit` | | 餐饮 | 外卖 `takeout`、堂食 `restaurant`、咖啡 `coffee`、零食 `snacks`、饮品 `drinks`、水果 `fruit` |
| 交通 | 打车 `taxi`、公交 `public-transit`、地铁 `subway`、火车 `train`、骑行 `cycling`、加油 `fuel`、充电 `charging`、停车 `parking`、高速 `highway` | | 交通 | 打车 `taxi`、公交 `public-transit`、地铁 `subway`、火车 `train`、骑行 `cycling`、加油 `fuel`、充电 `charging`、停车 `parking`、高速 `highway` |
| 购物 | 服饰 `clothing`、食品 `shopping-food`、杂货 `groceries`、数码 `digital`、家居 `home`、玩具 `toys` | | 购物 | 服饰 `clothing`、食品 `shopping-food`、杂货 `groceries`、数码 `digital`、家居 `home`、玩具 `toys` |
| 育儿 | 奶粉 `formula`、母婴 `maternal-baby`、童装 `children-clothing`、玩具 `children-toys`、学费 `children-tuition`、补剂 `children-supplements`、洗护 `children-care`、纸品 `children-paper`、其他 `children-other` |
| 娱乐 | 游戏 `games`、电影 `movies`、按摩 `massage`、运动 `sports`、门票 `tickets`、酒店 `hotel` | | 娱乐 | 游戏 `games`、电影 `movies`、按摩 `massage`、运动 `sports`、门票 `tickets`、酒店 `hotel` |
| 社交 | 红包 `red-packet`、礼物 `gift`、请客 `treating`、聚会 `gathering`、捐赠 `donation`、人情 `social-obligation` | | 社交 | 红包 `red-packet`、礼物 `gift`、请客 `treating`、聚会 `gathering`、捐赠 `donation`、人情 `social-obligation` |
| 医疗 | 药店 `pharmacy`、门诊 `outpatient`、急诊 `emergency`、疫苗 `vaccine`、住院 `hospitalization` | | 医疗 | 药店 `pharmacy`、门诊 `outpatient`、急诊 `emergency`、疫苗 `vaccine`、住院 `hospitalization` |

View File

@ -29,4 +29,30 @@ test.describe("报销:从支出详情记录", () => {
await page.goto("/stats"); await page.goto("/stats");
await expect(page.getByText("报销到账")).not.toBeVisible(); await expect(page.getByText("报销到账")).not.toBeVisible();
}); });
test("04-流水中的报销到账标记为不计入并使用蓝色删除线金额", async ({ page }) => {
await mockApi(page, { initialEntries: [makeEntry(), makeEntry({ id: "reimbursement", type: "income", amount: 1000, categoryId: "restaurant", reimbursementOfEntryId: "entry-lunch", note: "午餐报销" })] });
await (page);
const reimbursement = page.locator(".entry-row", { hasText: "午餐报销" });
await expect(reimbursement.getByText("不计入")).toBeVisible();
await expect(reimbursement.locator(".entry-value .reimbursement")).toHaveText("¥32.00");
await expect(reimbursement.locator(".entry-value .reimbursement")).not.toContainText("+");
await expect(reimbursement.locator(".entry-value .reimbursement")).toHaveCSS("color", "rgb(52, 120, 184)");
await expect(reimbursement.locator(".entry-value .reimbursement")).toHaveCSS("text-decoration-line", "line-through");
});
test("05-报销详情可以跳转到关联原支出", async ({ page }) => {
await mockApi(page, { initialEntries: [
makeEntry({ note: "午餐支出" }),
makeEntry({ id: "reimbursement", type: "income", amount: 1000, categoryId: "restaurant", reimbursementOfEntryId: "entry-lunch", note: "午餐报销" }),
] });
await (page);
await page.goto("/entries/reimbursement");
await page.getByRole("link", { name: /关联原支出/ }).click();
await expect(page).toHaveURL(/\/entries\/entry-lunch$/);
await expect(page.getByRole("button", { name: "记录报销" })).toBeVisible();
await expect(page.locator("textarea")).toHaveValue("午餐支出");
});
}); });