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 {
Ambulance,
Apple,
Baby,
Banknote,
BatteryCharging,
Bike,
@ -34,6 +35,7 @@ import {
Hospital,
Hotel,
House,
Milk,
ParkingCircle,
Pencil,
Pill,
@ -44,6 +46,7 @@ import {
ShoppingBag,
ShoppingBasket,
Shirt,
SoapDispenserDroplet,
Smartphone,
Soup,
Sparkles,
@ -55,6 +58,8 @@ import {
UsersRound,
WalletCards,
Zap,
CircleEllipsis,
PackageOpen,
type LucideIcon,
} from "@lucide/vue";
@ -130,6 +135,24 @@ export const categories: Record<EntryType, Category[]> = {
{ 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",
label: "娱乐",

View File

@ -696,6 +696,32 @@ input:focus-visible {
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 {
min-height: 260px;
display: grid;

View File

@ -20,7 +20,7 @@ import {
WalletCards,
X,
} from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
categories,
@ -151,11 +151,25 @@ const canRecordReimbursement = computed(() =>
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
const found = store.entries.find((item) => item.id === String(route.params.id));
if (found) hydrate(found);
hydrateCurrentEntry();
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) {
entry.value = value;
currency.value = value.currency;

View File

@ -999,7 +999,11 @@ async function shareLedger() {
</div>
<div class="entry-value">
<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>
</div>
</RouterLink>

View File

@ -17,18 +17,19 @@
### 支出
8 个。
9 个。
| 顺序 | 分类 | ID |
| --- | --- | --- |
| 1 | 餐饮 | `food` |
| 2 | 交通 | `transport` |
| 3 | 购物 | `shopping` |
| 4 | 娱乐 | `entertainment` |
| 5 | 社交 | `social` |
| 6 | 医疗 | `health` |
| 7 | 教育 | `education` |
| 8 | 缴费 | `bills` |
| 4 | 育儿 | `parenting` |
| 5 | 娱乐 | `entertainment` |
| 6 | 社交 | `social` |
| 7 | 医疗 | `health` |
| 8 | 教育 | `education` |
| 9 | 缴费 | `bills` |
### 收入
@ -54,6 +55,7 @@
| 餐饮 | 外卖 `takeout`、堂食 `restaurant`、咖啡 `coffee`、零食 `snacks`、饮品 `drinks`、水果 `fruit` |
| 交通 | 打车 `taxi`、公交 `public-transit`、地铁 `subway`、火车 `train`、骑行 `cycling`、加油 `fuel`、充电 `charging`、停车 `parking`、高速 `highway` |
| 购物 | 服饰 `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` |
| 社交 | 红包 `red-packet`、礼物 `gift`、请客 `treating`、聚会 `gathering`、捐赠 `donation`、人情 `social-obligation` |
| 医疗 | 药店 `pharmacy`、门诊 `outpatient`、急诊 `emergency`、疫苗 `vaccine`、住院 `hospitalization` |

View File

@ -29,4 +29,30 @@ test.describe("报销:从支出详情记录", () => {
await page.goto("/stats");
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("午餐支出");
});
});