品牌与配置 - 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger - 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile - 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端 - size-optimization 插件新增 NDK 版本强制统一(27.1.12297006) - 添加 app icon(direction_a_feather.png) Decimal 精度改造 - 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算 - 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD) - negateDecimal 零值特殊处理,避免产生 "-0" - 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移 - 去重指纹用 parseDecimal 归一化金额格式 - 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00) 架构变更 - mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean - TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方) - 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑 - 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用 - 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析 - 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建 - 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU) - ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析 - metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码 安全与持久化 - PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移 - storePersistence 从 persistTo 回调改为 subscribe 自动触发 - 持久化增加深比较跳过无变化写入 + 滞后重试 - 新增 flushPersistence(),App 切后台时立即刷盘 - CrashReporter 新增文件持久化(CrashFs 抽象) - settingsStore hydrate 期间跳过自动持久化(skipPersist 标志) 去重与管道 - batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1) - dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选 - OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求 - 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰 - 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API 其他改进 - recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日) - 预算进度按 posting 分录金额求和(不再仅过滤负数金额) - recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移 - 信用卡账单日/还款日限制不超过28,防止月份溢出 - 类别模糊匹配增加最小长度2,避免单字符误匹配 - 还款检测排除 refund 方向,避免退款被误判为还款 - WebDAV 同步增加 HTTP 日期格式解析 - Git 合并策略改为以 remote 为基础追加 local 独有行 性能优化 - commonStyles 全局 useMemo 缓存 - Button/Card 的 pressIn/pressOut 改用 useCallback - TransactionCard/AccountTreeNode 包裹 React.memo - PostingEditor 用 refs + 函数式更新避免重建 - CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历 - OCR 处理器单例复用,配置变更时才重建 - getLocalAuth/getAccessibilityBridge 结果缓存 测试 - 新增 decimal/constants/channelConfig/transactionBuilder 单测 - 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为 - billPipeline 新增不同格式金额配对、指纹唯一性测试
190 lines
7.2 KiB
TypeScript
190 lines
7.2 KiB
TypeScript
/**
|
||
* 趋势折线图(plan.md「5.2 图表与可视化」)。
|
||
* 采用 react-native-svg 绘制三次贝塞尔曲线及渐变填充区域。
|
||
*/
|
||
import React, { useMemo } from 'react';
|
||
import { StyleSheet, Text, View } from 'react-native';
|
||
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||
import { useTheme } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import type { Transaction } from '../../domain/types';
|
||
import { groupByMonth } from '../../domain/chartStats';
|
||
|
||
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]);
|
||
|
||
// 1. 数据配置
|
||
const width = 300;
|
||
const height = 120;
|
||
const paddingX = 20;
|
||
const paddingY = 15;
|
||
|
||
const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense]), 1);
|
||
|
||
// 2. 坐标转换计算
|
||
const pointsIncome = data.map((d, i) => {
|
||
const x = data.length > 1
|
||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
||
: width / 2;
|
||
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
|
||
return { x, y };
|
||
});
|
||
|
||
const pointsExpense = data.map((d, i) => {
|
||
const x = data.length > 1
|
||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
||
: width / 2;
|
||
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
|
||
return { x, y };
|
||
});
|
||
|
||
// 3. 贝塞尔曲线生成算法
|
||
const getBezierPath = (points: { x: number; y: number }[]) => {
|
||
if (points.length === 0) return '';
|
||
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
|
||
let path = `M ${points[0].x} ${points[0].y}`;
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const p0 = points[i];
|
||
const p1 = points[i + 1];
|
||
const cp1x = p0.x + (p1.x - p0.x) / 2;
|
||
const cp1y = p0.y;
|
||
const cp2x = p0.x + (p1.x - p0.x) / 2;
|
||
const cp2y = p1.y;
|
||
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
|
||
}
|
||
return path;
|
||
};
|
||
|
||
const getClosedBezierPath = (points: { x: number; y: number }[]) => {
|
||
if (points.length === 0) return '';
|
||
const linePath = getBezierPath(points);
|
||
const first = points[0];
|
||
const last = points[points.length - 1];
|
||
return `${linePath} L ${last.x} ${height - paddingY} L ${first.x} ${height - paddingY} Z`;
|
||
};
|
||
|
||
const incomePath = getBezierPath(pointsIncome);
|
||
const incomeClosedPath = getClosedBezierPath(pointsIncome);
|
||
|
||
const expensePath = getBezierPath(pointsExpense);
|
||
const expenseClosedPath = getClosedBezierPath(pointsExpense);
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}>
|
||
{t('report.trendTitle')}
|
||
</Text>
|
||
|
||
{data.length > 0 ? (
|
||
<View style={styles.chartWrapper}>
|
||
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
|
||
<Defs>
|
||
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
|
||
<Stop offset="100%" stopColor={theme.colors.financial.income} stopOpacity={0.0} />
|
||
</LinearGradient>
|
||
<LinearGradient id="expenseGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<Stop offset="0%" stopColor={theme.colors.financial.expense} stopOpacity={0.25} />
|
||
<Stop offset="100%" stopColor={theme.colors.financial.expense} stopOpacity={0.0} />
|
||
</LinearGradient>
|
||
</Defs>
|
||
|
||
{/* 网格线(只绘制一条底线和中线) */}
|
||
<Path
|
||
d={`M ${paddingX} ${height / 2} L ${width - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${width - paddingX} ${height - paddingY}`}
|
||
stroke={theme.colors.divider}
|
||
strokeWidth={1}
|
||
strokeDasharray="4 4"
|
||
/>
|
||
|
||
{/* 收入填充与折线 */}
|
||
{incomeClosedPath ? <Path d={incomeClosedPath} fill="url(#incomeGrad)" /> : null}
|
||
{incomePath ? (
|
||
<Path
|
||
d={incomePath}
|
||
fill="none"
|
||
stroke={theme.colors.financial.income}
|
||
strokeWidth={2.5}
|
||
strokeLinecap="round"
|
||
/>
|
||
) : null}
|
||
|
||
{/* 支出填充与折线 */}
|
||
{expenseClosedPath ? <Path d={expenseClosedPath} fill="url(#expenseGrad)" /> : null}
|
||
{expensePath ? (
|
||
<Path
|
||
d={expensePath}
|
||
fill="none"
|
||
stroke={theme.colors.financial.expense}
|
||
strokeWidth={2.5}
|
||
strokeLinecap="round"
|
||
/>
|
||
) : null}
|
||
|
||
{/* 数据点标记 */}
|
||
{pointsIncome.map((p, i) => (
|
||
<Circle
|
||
key={`income-dot-${i}`}
|
||
cx={p.x}
|
||
cy={p.y}
|
||
r={3.5}
|
||
fill={theme.colors.bgSecondary}
|
||
stroke={theme.colors.financial.income}
|
||
strokeWidth={2}
|
||
/>
|
||
))}
|
||
{pointsExpense.map((p, i) => (
|
||
<Circle
|
||
key={`expense-dot-${i}`}
|
||
cx={p.x}
|
||
cy={p.y}
|
||
r={3.5}
|
||
fill={theme.colors.bgSecondary}
|
||
stroke={theme.colors.financial.expense}
|
||
strokeWidth={2}
|
||
/>
|
||
))}
|
||
</Svg>
|
||
|
||
{/* 月度文本 X 轴 */}
|
||
<View style={styles.xAxis}>
|
||
{data.map((d, i) => (
|
||
<Text
|
||
key={`x-label-${i}`}
|
||
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10, fontFamily: theme.typography.caption.fontFamily }]}
|
||
>
|
||
{d.month.slice(5)}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>
|
||
{t('common.noData')}
|
||
</Text>
|
||
)}
|
||
|
||
<View style={styles.legend}>
|
||
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||
{t('home.income')}
|
||
</Text>
|
||
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||
{t('home.expense')}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { gap: 10 },
|
||
chartWrapper: { marginTop: 6 },
|
||
xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 },
|
||
legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
|
||
legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 },
|
||
});
|