财务折线:概览卡画净资产趋势(配服务端 /timeline)laoyang/FamilyBook#24
用户原话(2026-10-28):「鸿蒙端对齐网页端」。财务概览卡原来「折线图占位 (第二轮接图表能力后填)」,现在: 1) FbLedgerPoint 类型 + parseLedgerTimeline(读 /timeline 的 items) 2) loadEntries 里顺带拉 timeline(单独缓存键 ledger_timeline_<id>) 3) timelinePath():时序点 → 绝对坐标折线 path(Path 组件画,Y 轴按 min..max 归一,不是从 0 起 —— 金额平稳时不贴底);<2 点时给占位文案。 编译 BUILD SUCCESSFUL;守卫 _fb_arkts_guard.js 1324/0;服务端冒烟 _i24_timeline_api.py 8/0 + 反证命中。
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
import {
|
import {
|
||||||
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
|
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
|
||||||
FbMark,
|
FbMark,
|
||||||
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNoteBlock, FbNotificationItem, FbResourceItem,
|
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbLedgerPoint, FbNoteItem, FbNoteBlock, FbNotificationItem, FbResourceItem,
|
||||||
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
|
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
|
||||||
FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt,
|
FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt,
|
||||||
} from '../model/Types';
|
} from '../model/Types';
|
||||||
@@ -230,6 +230,10 @@ interface RawLedgerSummary {
|
|||||||
asset?: number; debt?: number; net?: number; count?: number;
|
asset?: number; debt?: number; net?: number; count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawLedgerPoint {
|
||||||
|
date?: string; net?: number; asset?: number; debt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawResource {
|
interface RawResource {
|
||||||
id?: number; title?: string; content?: string; owner_id?: number;
|
id?: number; title?: string; content?: string; owner_id?: number;
|
||||||
owner_name?: string | null; avatar?: string | null; avatar_color?: string | null;
|
owner_name?: string | null; avatar?: string | null; avatar_color?: string | null;
|
||||||
@@ -515,6 +519,26 @@ export function parseLedgerEntries(json: string): FbLedgerDetail {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 财务折线时序 → FbLedgerPoint[](工单 #24;`GET /api/v1/ledger/<id>/timeline`) */
|
||||||
|
export function parseLedgerTimeline(json: string): FbLedgerPoint[] {
|
||||||
|
const root: Record<string, Object> | null = asObject(json);
|
||||||
|
if (root === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows: RawLedgerPoint[] = (root['items'] !== undefined && root['items'] !== null)
|
||||||
|
? arr<RawLedgerPoint>(root['items'] as RawLedgerPoint[]) : [];
|
||||||
|
const out: FbLedgerPoint[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
out.push({
|
||||||
|
date: s(r.date),
|
||||||
|
netCent: n(r.net),
|
||||||
|
assetCent: n(r.asset),
|
||||||
|
debtCent: n(r.debt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** id 数组的容错转换(服务端可能给 null) */
|
/** id 数组的容错转换(服务端可能给 null) */
|
||||||
function idList(v: number[] | null | undefined): number[] {
|
function idList(v: number[] | null | undefined): number[] {
|
||||||
const xs: number[] = arr<number>(v);
|
const xs: number[] = arr<number>(v);
|
||||||
|
|||||||
@@ -901,6 +901,18 @@ export interface FbLedgerDetail {
|
|||||||
debtCent: number;
|
debtCent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一个时序点(工单 #24 财务折线:`GET /api/v1/ledger/<id>/timeline`) */
|
||||||
|
export interface FbLedgerPoint {
|
||||||
|
/** 日期 YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
/** 净资产(分) */
|
||||||
|
netCent: number;
|
||||||
|
/** 总资产(分) */
|
||||||
|
assetCent: number;
|
||||||
|
/** 总负债(分) */
|
||||||
|
debtCent: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 资源(resources)
|
// 资源(resources)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
|
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
|
||||||
import { FbAppState, fbAppState } from '../../common/Store';
|
import { FbAppState, fbAppState } from '../../common/Store';
|
||||||
import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail} from '../../model/Types';
|
import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail, FbLedgerPoint } from '../../model/Types';
|
||||||
import { fbLedgerKindName, fbMoney } from '../../model/DemoData';
|
import { fbLedgerKindName, fbMoney } from '../../model/DemoData';
|
||||||
import { parseLedger, parseLedgerEntries } from '../../data/FbParse';
|
import { parseLedger, parseLedgerEntries, parseLedgerTimeline } from '../../data/FbParse';
|
||||||
import { loadModule, loadPath } from '../../data/FbModuleLoad';
|
import { loadModule, loadPath } from '../../data/FbModuleLoad';
|
||||||
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
|
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
|
||||||
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
|
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
|
||||||
@@ -76,6 +76,8 @@ export struct FbLedgerPage {
|
|||||||
@Local netNow: number = 0;
|
@Local netNow: number = 0;
|
||||||
@Local assetNow: number = 0;
|
@Local assetNow: number = 0;
|
||||||
@Local debtNow: number = 0;
|
@Local debtNow: number = 0;
|
||||||
|
/** 净资产折线时序(分;工单 #24) */
|
||||||
|
@Local timeline: FbLedgerPoint[] = [];
|
||||||
/** 「记一笔」浮层是否打开 */
|
/** 「记一笔」浮层是否打开 */
|
||||||
@Local entryOpen: boolean = false;
|
@Local entryOpen: boolean = false;
|
||||||
/**
|
/**
|
||||||
@@ -153,7 +155,7 @@ export struct FbLedgerPage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉某本账的明细(含汇总) */
|
/** 拉某本账的明细(含汇总)+ 折线时序(工单 #24) */
|
||||||
private async loadEntries(id: number): Promise<void> {
|
private async loadEntries(id: number): Promise<void> {
|
||||||
// ⚠️ 用 `loadPath`(路径带 id,没法登记在静态模块表里)。
|
// ⚠️ 用 `loadPath`(路径带 id,没法登记在静态模块表里)。
|
||||||
const key: string = 'ledger_detail_' + id.toString();
|
const key: string = 'ledger_detail_' + id.toString();
|
||||||
@@ -170,6 +172,13 @@ export struct FbLedgerPage {
|
|||||||
(note: string): void => {
|
(note: string): void => {
|
||||||
this.syncNote = note;
|
this.syncNote = note;
|
||||||
});
|
});
|
||||||
|
// 🔴 折线时序单独拉(服务端 `timeline` 用的是盘点快照,与明细不是一个数据源)
|
||||||
|
const tkey: string = 'ledger_timeline_' + id.toString();
|
||||||
|
await loadPath<FbLedgerPoint[]>(tkey, '/api/v1/ledger/' + id.toString() + '/timeline',
|
||||||
|
parseLedgerTimeline,
|
||||||
|
(pts: FbLedgerPoint[]): void => {
|
||||||
|
this.timeline = pts;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -357,7 +366,7 @@ export struct FbLedgerPage {
|
|||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
// 折线图占位(第二轮接图表能力后填)
|
// 净资产折线(工单 #24:真实曲线,不再空占位)
|
||||||
Column({ space: 4 }) {
|
Column({ space: 4 }) {
|
||||||
Row({ space: 6 }) {
|
Row({ space: 6 }) {
|
||||||
Image($r('app.media.fb_ic_chart'))
|
Image($r('app.media.fb_ic_chart'))
|
||||||
@@ -365,17 +374,35 @@ export struct FbLedgerPage {
|
|||||||
.height(13)
|
.height(13)
|
||||||
.renderMode(ImageRenderMode.Template)
|
.renderMode(ImageRenderMode.Template)
|
||||||
.colorFilter(fbTint(FbTint.faint))
|
.colorFilter(fbTint(FbTint.faint))
|
||||||
Text('净资产变化(盘点成线)')
|
Text('净资产变化(盘点成线' + (this.timeline.length > 0 ? ' · ' + this.timeline.length.toString() + ' 点' : '') + ')')
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.fontColor(FbColors.faint)
|
.fontColor(FbColors.faint)
|
||||||
}
|
}
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
Column()
|
// 有数据画折线;没数据保留占位底
|
||||||
|
if (this.timeline.length > 1) {
|
||||||
|
Path()
|
||||||
|
.width('100%')
|
||||||
|
.height(56)
|
||||||
|
.commands(this.timelinePath())
|
||||||
|
.fillOpacity(0)
|
||||||
|
.stroke(this.accent)
|
||||||
|
.strokeWidth(1.5)
|
||||||
|
.strokeLineJoin(LineJoinStyle.Round)
|
||||||
|
} else {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(this.timeline.length === 1 ? '只有一笔,记下一笔才连得出线' : '记几笔、盘点几次,这里就会长出曲线')
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor(FbColors.faint)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height(56)
|
.height(56)
|
||||||
.borderRadius(FbMetric.radiusS)
|
.borderRadius(FbMetric.radiusS)
|
||||||
.backgroundColor(FbColors.inputBg)
|
.backgroundColor(FbColors.inputBg)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
@@ -642,6 +669,50 @@ export struct FbLedgerPage {
|
|||||||
return this.books.length > 0 ? this.books[0].id : 0;
|
return this.books.length > 0 ? this.books[0].id : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净资产折线 path(工单 #24)。
|
||||||
|
*
|
||||||
|
* 时序点 → 一条从左到右的折线。Y 轴按 min..max 归一(不是从 0 起),
|
||||||
|
* 否则金额平稳时曲线会"贴底"看不到波动。单点/零点由 UI 分支兜底。
|
||||||
|
* ⚠️ Path 的 commands 用「绝对坐标」的 SVG 子集:`M x y L x y ...`。
|
||||||
|
*/
|
||||||
|
private timelinePath(): string {
|
||||||
|
const pts: FbLedgerPoint[] = this.timeline;
|
||||||
|
if (pts.length < 2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const W: number = 320; // 虚拟画布宽(Path 会随 width('100%') 拉伸)
|
||||||
|
const H: number = 56;
|
||||||
|
const pad: number = 4;
|
||||||
|
let lo: number = pts[0].netCent;
|
||||||
|
let hi: number = pts[0].netCent;
|
||||||
|
for (const p of pts) {
|
||||||
|
if (p.netCent < lo) {
|
||||||
|
lo = p.netCent;
|
||||||
|
}
|
||||||
|
if (p.netCent > hi) {
|
||||||
|
hi = p.netCent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 全平(最高最低相等)→ 人为放宽 ±1,让线画在中线而不是除零
|
||||||
|
if (hi - lo < 1) {
|
||||||
|
hi = lo + 1;
|
||||||
|
lo = lo - 1;
|
||||||
|
}
|
||||||
|
let cmd: string = '';
|
||||||
|
for (let i: number = 0; i < pts.length; i++) {
|
||||||
|
const x: number = pad + (W - pad * 2) * (i / (pts.length - 1));
|
||||||
|
const t: number = (pts[i].netCent - lo) / (hi - lo);
|
||||||
|
const y: number = H - pad - (H - pad * 2) * t;
|
||||||
|
if (i === 0) {
|
||||||
|
cmd = 'M ' + x.toFixed(1) + ' ' + y.toFixed(1);
|
||||||
|
} else {
|
||||||
|
cmd += ' L ' + x.toFixed(1) + ' ' + y.toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
private currentBook(): FbLedgerBook {
|
private currentBook(): FbLedgerBook {
|
||||||
const hit: FbLedgerBook | undefined = this.books.find((b: FbLedgerBook): boolean => b.id === this.currentId());
|
const hit: FbLedgerBook | undefined = this.books.find((b: FbLedgerBook): boolean => b.id === this.currentId());
|
||||||
if (hit !== undefined) {
|
if (hit !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user