DriftLedger/src/components/FormModal.tsx
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

157 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 通用模态表单plan.md 管理页 CRUD
*
* 底部弹出的模态对话框,包含多个文本输入字段 + 确认/取消按钮。
* 供分类/标签/预算/信用卡/规则管理页的「添加/编辑」复用。
*
* 用法:
* <FormModal
* visible={true}
* title="添加分类"
* fields={[
* { key: 'name', label: '名称', placeholder: '餐饮' },
* { key: 'linkedAccount', label: '关联账户', placeholder: 'Expenses:餐饮' },
* ]}
* initialValues={{ name: '', linkedAccount: '' }}
* onConfirm={(values) => { /* 保存 *\/ }}
* onCancel={() => { /* 关闭 *\/ }}
* />
*/
import React, { useEffect, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { Button } from './Button';
export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */
key: string;
/** 显示标签。 */
label: string;
/** 占位提示。 */
placeholder?: string;
/** 默认值(新增时为空,编辑时预填)。 */
defaultValue?: string;
/** 键盘类型。 */
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
/** 多行输入。 */
multiline?: boolean;
}
interface FormModalProps {
visible: boolean;
title: string;
fields: FormField[];
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string;
}
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const [values, setValues] = useState<Record<string, string>>({});
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => {
if (visible) {
const init: Record<string, string> = {};
for (const f of fields) {
init[f.key] = f.defaultValue ?? '';
}
setValues(init);
}
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={styles.overlay} onPress={onCancel}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</View>
<ScrollView style={styles.body}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
style={[styles.input, {
backgroundColor: theme.colors.bgTertiary,
color: theme.colors.fgPrimary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm,
}]}
/>
</View>
))}
</ScrollView>
<View style={styles.actions}>
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
<View style={{ flex: 1 }} />
<Button label={confirmLabel ?? t('common.confirm')} onPress={() => onConfirm(values)} />
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
maxHeight: '80%',
padding: 20,
paddingBottom: 36,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 12,
borderBottomWidth: 1,
marginBottom: 12,
},
body: {
maxHeight: 400,
},
field: {
marginBottom: 12,
},
input: {
borderWidth: 1,
padding: 11,
fontSize: 15,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
},
});