20260629222128
This commit is contained in:
1 parent
6ab4ae9c77
commit
96299ba9a2
20 files changed
+614
-409
No files matched your search
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
import urllib.request
|
||||
from PIL import Image
|
||||
|
||||
URL = ("http://118.89.70.199:9001/test/finance-category-default/income/hongbao.webp"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD"
|
||||
"&X-Amz-Credential=54WPUR104SA9X00EKWS3%2F20260629%2Fus-east-1%2Fs3%2Faws4_request"
|
||||
"&X-Amz-Date=20260629T105003Z&X-Amz-Expires=86400"
|
||||
"&X-Amz-Security-Token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJleHAiOjE3ODI3NzMyMDUsInBhcmVudCI6Im9uZWFvIn0.zkP6L1zg_LPy1UuK-3-VT6LEWuhLNickKWTWDly_PzJCnwIRPS4naSZE9Lx84HtXPVVhxaZguHXl65AXjROJZA"
|
||||
"&X-Amz-Signature=b763181ad8bf74deb2ac55ac18402d26a91075088c9c109cfd3a4da6cca7b38f"
|
||||
"&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject")
|
||||
|
||||
out_path = r"D:\workspace\code\app\.workbuddy\hongbao.webp"
|
||||
|
||||
req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = resp.read()
|
||||
print("Downloaded bytes:", len(data))
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
img = Image.open(out_path)
|
||||
print("Format:", img.format)
|
||||
print("Size:", img.size)
|
||||
print("Mode:", img.mode)
|
||||
|
||||
# Force load pixel data
|
||||
img.load()
|
||||
|
||||
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||
print("Has alpha channel:", has_alpha)
|
||||
|
||||
if img.mode == "P":
|
||||
# Convert palette to RGBA to inspect real pixels
|
||||
rgba = img.convert("RGBA")
|
||||
elif img.mode != "RGBA":
|
||||
rgba = img.convert("RGBA")
|
||||
else:
|
||||
rgba = img
|
||||
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
|
||||
def info(x, y):
|
||||
r, g, b, a = px[x, y]
|
||||
return f"({x},{y}) RGBA=({r},{g},{b},{a})"
|
||||
|
||||
print("\n--- Corner pixels ---")
|
||||
print("TL", info(0, 0))
|
||||
print("TR", info(w - 1, 0))
|
||||
print("BL", info(0, h - 1))
|
||||
print("BR", info(w - 1, h - 1))
|
||||
|
||||
print("\n--- Edge midpoints ---")
|
||||
print("Top-mid", info(w // 2, 0))
|
||||
print("Bottom-mid", info(w // 2, h - 1))
|
||||
print("Left-mid", info(0, h // 2))
|
||||
print("Right-mid", info(w - 1, h // 2))
|
||||
|
||||
# Statistics over all pixels
|
||||
total = w * h
|
||||
transparent = 0 # alpha == 0
|
||||
near_transparent = 0 # alpha < 255 and > 0
|
||||
fully_opaque = 0 # alpha == 255
|
||||
white_pixels = 0 # opaque & RGB ~ (255,255,255)
|
||||
near_white_bg = 0 # alpha==0 OR rgb near white
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
transparent += 1
|
||||
elif a < 255:
|
||||
near_transparent += 1
|
||||
else:
|
||||
fully_opaque += 1
|
||||
if r > 245 and g > 245 and b > 245:
|
||||
white_pixels += 1
|
||||
|
||||
print("\n--- Global statistics ---")
|
||||
print("Total pixels:", total)
|
||||
print("Fully transparent (a==0):", transparent, f"({100*transparent/total:.2f}%)")
|
||||
print("Semi-transparent (0<a<255):", near_transparent, f"({100*near_transparent/total:.2f}%)")
|
||||
print("Fully opaque (a==255):", fully_opaque, f"({100*fully_opaque/total:.2f}%)")
|
||||
print("Opaque & near-white:", white_pixels, f"({100*white_pixels/total:.2f}%)")
|
||||
|
||||
# Border-only statistics (outer 2px ring) - this is what reveals background
|
||||
def is_border(x, y, w, h, t=2):
|
||||
return x < t or y < t or x >= w - t or y >= h - t
|
||||
|
||||
border_total = 0
|
||||
border_trans = 0
|
||||
border_white = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if is_border(x, y, w, h):
|
||||
border_total += 1
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
border_trans += 1
|
||||
elif r > 245 and g > 245 and b > 245:
|
||||
border_white += 1
|
||||
|
||||
print("\n--- Border ring (outer 2px) ---")
|
||||
print("Border pixels:", border_total)
|
||||
print("Border transparent:", border_trans, f"({100*border_trans/border_total:.2f}%)")
|
||||
print("Border near-white opaque:", border_white, f"({100*border_white/border_total:.2f}%)")
|
||||
|
||||
print("\n=== CONCLUSION ===")
|
||||
if border_trans / border_total > 0.5:
|
||||
print("BACKGROUND IS TRANSPARENT (alpha channel, mostly a==0 at edges)")
|
||||
elif border_white / border_total > 0.5:
|
||||
print("BACKGROUND IS WHITE (opaque near-white pixels at edges)")
|
||||
else:
|
||||
print("Background is neither clearly transparent nor white - inspect manually")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
src = r"D:\workspace\code\app\.workbuddy\hongbao.webp"
|
||||
img = Image.open(src).convert("RGBA")
|
||||
w, h = img.size
|
||||
tile = 16
|
||||
|
||||
# Panel 1: checkerboard background (shows transparency)
|
||||
def make_checker(width, height, t):
|
||||
cb = Image.new("RGB", (width, height), (255, 255, 255))
|
||||
d = ImageDraw.Draw(cb)
|
||||
for y in range(0, height, t):
|
||||
for x in range(0, width, t):
|
||||
if (x // t + y // t) % 2 == 0:
|
||||
d.rectangle([x, y, x + t - 1, y + t - 1], fill=(204, 204, 204))
|
||||
return cb
|
||||
|
||||
checker = make_checker(w, h, tile).convert("RGBA")
|
||||
panel1 = Image.alpha_composite(checker, img).convert("RGB")
|
||||
|
||||
# Panel 2: white background
|
||||
white_bg = Image.new("RGBA", (w, h), (255, 255, 255, 255))
|
||||
panel2 = Image.alpha_composite(white_bg, img).convert("RGB")
|
||||
|
||||
# Panel 3: alpha channel as grayscale (white = opaque, black = transparent)
|
||||
alpha = img.split()[3]
|
||||
panel3 = Image.merge("RGB", (alpha, alpha, alpha))
|
||||
|
||||
# Combine with labels
|
||||
gap = 10
|
||||
label_h = 28
|
||||
total_w = w * 3 + gap * 2
|
||||
total_h = h + label_h
|
||||
canvas = Image.new("RGB", (total_w, total_h), (245, 245, 245))
|
||||
canvas.paste(panel1, (0, label_h))
|
||||
canvas.paste(panel2, (w + gap, label_h))
|
||||
canvas.paste(panel3, (w * 2 + gap * 2, label_h))
|
||||
|
||||
d = ImageDraw.Draw(canvas)
|
||||
d.text((4, 6), "Transparent bg (checker)", fill=(0, 0, 0))
|
||||
d.text((w + gap + 4, 6), "White bg", fill=(0, 0, 0))
|
||||
d.text((w * 2 + gap * 2 + 4, 6), "Alpha channel", fill=(0, 0, 0))
|
||||
|
||||
out = r"D:\workspace\code\app\.workbuddy\hongbao_bg_compare.png"
|
||||
canvas.save(out)
|
||||
print("Saved:", out)
|
||||
@@ -0,0 +1,22 @@
|
||||
# 2026-06-29 工作日志
|
||||
|
||||
## finance_default 图标背景色检查
|
||||
- 检查 `finance-category-default/income/hongbao.webp` 背景是否透明
|
||||
- 使用 Python + Pillow 下载并分析像素 alpha 通道
|
||||
- 结果:**背景为透明色**(RGBA 模式,外层 2px 边缘 100% 完全透明 a==0,全局 38.15% 像素完全透明)
|
||||
- 图片尺寸 256x256,webp 格式
|
||||
- 生成对比图 `hongbao_bg_compare.png`(透明棋盘格 / 白色背景 / alpha 通道三视图)
|
||||
- 分析脚本:`.workbuddy/check_bg.py`、`.workbuddy/make_compare.py`
|
||||
|
||||
## finance/add/index.tsx 修复
|
||||
- 修复第 302 行乱码:`璁拌处` → `记账`(UTF-8 被误解释为 GBK)
|
||||
|
||||
## FinanceCategoryIcon 背景色修复
|
||||
- 移除 `FinanceCategoryIcon` 内部 View 的 `backgroundColor: "#F3F4F6"`(DEFAULT_BG)
|
||||
- 根因:该背景色遮挡了外层选中态 `bg-accent` 绿色,导致图片透明区域显示灰色而非绿色
|
||||
- 选中态背景由外层 `CategoryItem` 的 `bg-accent` / `bg-gray-100` 控制
|
||||
|
||||
## 记账 add 页面 BottomSheet 布局调整
|
||||
- 分类 icon+名称与日期合并到同一行(左右分布)
|
||||
- 金额移除 Label,居中放大(text-4xl)
|
||||
- 备注保持不变
|
||||
@@ -259,8 +259,7 @@ export default function ChatHistoryPage() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-lg font-semibold text-gray-900">筛选聊天记录</Text>
|
||||
<TouchableOpacity
|
||||
@@ -328,7 +327,6 @@ export default function ChatHistoryPage() {
|
||||
<Button.Label>应用</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -551,8 +551,7 @@ export default function HomeSettings() {
|
||||
<BottomSheet isOpen={showFamilyNameSheet} onOpenChange={setShowFamilyNameSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<TextField>
|
||||
<Label>家庭名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -567,7 +566,6 @@ export default function HomeSettings() {
|
||||
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -723,8 +723,7 @@ export default function UserSetting() {
|
||||
<BottomSheet isOpen={showNameSheet} onOpenChange={setShowNameSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content className="gap-4">
|
||||
<TextField>
|
||||
<Label>姓名</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -739,7 +738,6 @@ export default function UserSetting() {
|
||||
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -747,8 +745,7 @@ export default function UserSetting() {
|
||||
<BottomSheet isOpen={showPhoneSheet} onOpenChange={setShowPhoneSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content className="gap-4">
|
||||
<TextField>
|
||||
<Label>手机号</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -763,7 +760,6 @@ export default function UserSetting() {
|
||||
<Button onPress={handlePhone} isDisabled={isLoading || !phoneForm.phone.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -485,8 +485,7 @@ export default function ClothingHome() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-lg font-semibold text-gray-900">筛选衣服</Text>
|
||||
<TouchableOpacity
|
||||
@@ -545,7 +544,6 @@ export default function ClothingHome() {
|
||||
<Button.Label>应用</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -659,9 +659,9 @@ export default function ClothingMetadataManage() {
|
||||
>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
{form && (
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<>
|
||||
<Text className="text-lg font-semibold text-gray-900">{renderFormTitle()}</Text>
|
||||
|
||||
<TextField isRequired>
|
||||
@@ -743,7 +743,7 @@ export default function ClothingMetadataManage() {
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
|
||||
@@ -625,8 +625,7 @@ export default function FinanceAnalysis() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-lg font-semibold text-gray-900">筛选分析</Text>
|
||||
<TouchableOpacity
|
||||
@@ -805,7 +804,6 @@ export default function FinanceAnalysis() {
|
||||
<Button.Label>应用</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Button, Label, Menu, TextField } from "heroui-native";
|
||||
import { Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Button, Label, TextField } from "heroui-native";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import dayjs from "dayjs";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { buildFileUrl } from "@/utils/file";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import DateTimeRange from "@/components/DateTimeRange";
|
||||
import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon";
|
||||
@@ -46,7 +47,7 @@ const moneyFormatter = new Intl.NumberFormat("zh-CN", {
|
||||
});
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return moneyFormatter.format(value || 0);
|
||||
return moneyFormatter.format(Math.abs(value));
|
||||
}
|
||||
|
||||
function createDefaultFilter(): FilterState {
|
||||
@@ -73,7 +74,6 @@ export default function Home() {
|
||||
const [showDateRange, setShowDateRange] = useState(false);
|
||||
const [bills, setBills] = useState<FinanceBill[]>([]);
|
||||
const [activeBill, setActiveBill] = useState<FinanceBill | null>(null);
|
||||
const showActionMenu = activeBill != null;
|
||||
|
||||
const selectedCategory = useMemo(
|
||||
() => categories.find((item) => item.id === filter.categoryId) ?? null,
|
||||
@@ -167,6 +167,7 @@ export default function Home() {
|
||||
" c.type as category_type,\n" +
|
||||
" c.icon_file_key as category_icon_file_key,\n" +
|
||||
" coalesce(m.nickname, u.name) as member_nickname,\n" +
|
||||
" u.avatar as member_avatar,\n" +
|
||||
" coalesce(um.nickname, uu.name) as update_member_nickname\n" +
|
||||
"from b_finance f\n" +
|
||||
"left join b_finance_category c on c.id = f.category_id\n" +
|
||||
@@ -287,7 +288,12 @@ export default function Home() {
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-col gap-1.5 flex-1 mr-3">
|
||||
<Text className="text-base font-semibold text-gray-900">
|
||||
{filter.startDate} 至 {filter.endDate}
|
||||
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{dayjs(filter.startDate).year() !== dayjs(filter.endDate).year()
|
||||
? `${dayjs(filter.startDate).format("YYYY")} / ${dayjs(filter.endDate).format("YYYY")}`
|
||||
: dayjs(filter.startDate).format("YYYY年")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -314,15 +320,21 @@ export default function Home() {
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex-1 rounded-xl bg-white px-4 py-3">
|
||||
<Text className="text-xs text-gray-500">支出合计</Text>
|
||||
<Text className="mt-1 text-xl font-bold text-rose-500">
|
||||
¥{formatMoney(totals.expense)}
|
||||
<Text className="text-xs text-gray-500">支出</Text>
|
||||
<Text className="mt-1 text-lg font-semibold text-rose-500">
|
||||
{formatMoney(totals.expense)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 rounded-xl bg-white px-4 py-3">
|
||||
<Text className="text-xs text-gray-500">收入合计</Text>
|
||||
<Text className="mt-1 text-xl font-bold text-emerald-600">
|
||||
¥{formatMoney(totals.income)}
|
||||
<Text className="text-xs text-gray-500">收入</Text>
|
||||
<Text className="mt-1 text-lg font-semibold text-emerald-600">
|
||||
{formatMoney(totals.income)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 rounded-xl bg-white px-4 py-3">
|
||||
<Text className="text-xs text-gray-500">结余</Text>
|
||||
<Text className={`mt-1 text-lg font-semibold ${totals.income - totals.expense >= 0 ? "text-emerald-600" : "text-rose-500"}`}>
|
||||
{formatMoney(totals.income - totals.expense)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -341,8 +353,8 @@ export default function Home() {
|
||||
{dayjs(billSection.title).format("YYYY年MM月DD日")}
|
||||
</Text>
|
||||
<View className="flex-row gap-3">
|
||||
<Text className="text-xs text-gray-500">-¥{formatMoney(billSection.expenseTotal)}</Text>
|
||||
<Text className="text-xs text-gray-500">+¥{formatMoney(billSection.incomeTotal)}</Text>
|
||||
<Text className="text-xs text-gray-500">-{formatMoney(billSection.expenseTotal)}</Text>
|
||||
<Text className="text-xs text-gray-500">+{formatMoney(billSection.incomeTotal)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -350,47 +362,63 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: FinanceBill }) => {
|
||||
({ item, index, section }: { item: FinanceBill; index: number; section: { data: FinanceBill[] } }) => {
|
||||
const isIncome = item.category_type === FinanceType.Income;
|
||||
const isOwnRecord = String(item.create_by ?? "") === String(userId ?? "");
|
||||
const remark = item.remark?.trim();
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === section.data.length - 1;
|
||||
const radiusClass = isFirst && isLast
|
||||
? "rounded-xl"
|
||||
: isFirst
|
||||
? "rounded-t-xl"
|
||||
: isLast
|
||||
? "rounded-b-xl"
|
||||
: "";
|
||||
|
||||
const memberAvatarUri = !isOwnRecord && item.member_avatar
|
||||
? buildFileUrl(item.member_avatar) || null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center bg-white px-3 py-3 border-b border-gray-100"
|
||||
className={`flex-row items-center bg-white px-3 py-3 ${isLast ? "" : "border-b border-gray-100"} ${radiusClass}`}
|
||||
activeOpacity={0.78}
|
||||
delayLongPress={260}
|
||||
onLongPress={() => setActiveBill(item)}
|
||||
>
|
||||
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center">
|
||||
<FinanceCategoryIcon
|
||||
fileKey={item.category_icon_file_key}
|
||||
label={item.category_name}
|
||||
size={30}
|
||||
/>
|
||||
<View className="relative">
|
||||
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center">
|
||||
<FinanceCategoryIcon
|
||||
fileKey={item.category_icon_file_key}
|
||||
label={item.category_name}
|
||||
size={30}
|
||||
/>
|
||||
</View>
|
||||
{memberAvatarUri && (
|
||||
<Image
|
||||
source={{ uri: memberAvatarUri }}
|
||||
className="absolute -right-1 -bottom-1 w-4 h-4 rounded-full border border-white bg-gray-200"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="ml-3 flex-1">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||
{item.category_name || "未分类"}
|
||||
</Text>
|
||||
{!isOwnRecord && !!item.member_nickname && (
|
||||
<Text className="text-[11px] text-gray-500" numberOfLines={1}>
|
||||
{item.member_nickname}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className="mt-1 text-xs text-gray-500" numberOfLines={1}>
|
||||
{remark || "无备注"}
|
||||
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||
{item.category_name || "未分类"}
|
||||
</Text>
|
||||
{!!remark && (
|
||||
<Text className="mt-0.5 text-xs text-gray-500" numberOfLines={1}>
|
||||
{remark}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="items-end ml-3">
|
||||
<Text
|
||||
className={`text-base font-semibold ${isIncome ? "text-emerald-600" : "text-gray-900"}`}
|
||||
>
|
||||
{isIncome ? "+" : "-"}¥{formatMoney(Number(item.amount))}
|
||||
{isIncome ? "+" : "-"}{formatMoney(Number(item.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -456,8 +484,7 @@ export default function Home() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-lg font-semibold text-gray-900">筛选记账</Text>
|
||||
<TouchableOpacity
|
||||
@@ -470,7 +497,7 @@ export default function Home() {
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Label>日期范围</Label>
|
||||
<Label className="mb-2">日期范围</Label>
|
||||
<TouchableOpacity
|
||||
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
|
||||
activeOpacity={0.75}
|
||||
@@ -484,7 +511,7 @@ export default function Home() {
|
||||
</View>
|
||||
|
||||
<TextField>
|
||||
<Label>备注关键词</Label>
|
||||
<Label className="mb-2">备注关键词</Label>
|
||||
<BottomSheet.Input
|
||||
value={draftFilter.keyword}
|
||||
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
|
||||
@@ -494,22 +521,34 @@ export default function Home() {
|
||||
</TextField>
|
||||
|
||||
<View>
|
||||
<Label>分类</Label>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<TouchableOpacity
|
||||
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
|
||||
draftFilter.categoryId == null
|
||||
? "border-gray-900 bg-gray-100"
|
||||
: "border-gray-100 bg-white"
|
||||
}`}
|
||||
activeOpacity={0.75}
|
||||
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
|
||||
>
|
||||
<SearchIcon size={24} color="#6B7280" />
|
||||
<Text className="mt-1.5 text-xs text-gray-700">全部</Text>
|
||||
</TouchableOpacity>
|
||||
{categories.map(renderCategoryOption)}
|
||||
</View>
|
||||
<Label className="mb-2">分类</Label>
|
||||
<ScrollView
|
||||
style={{ maxHeight: 240 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 支出 */}
|
||||
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">支出</Text>
|
||||
<View className="flex-row flex-wrap gap-2 mb-3">
|
||||
<TouchableOpacity
|
||||
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
|
||||
draftFilter.categoryId == null
|
||||
? "border-gray-900 bg-gray-100"
|
||||
: "border-gray-100 bg-white"
|
||||
}`}
|
||||
activeOpacity={0.75}
|
||||
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
|
||||
>
|
||||
<SearchIcon size={24} color="#6B7280" />
|
||||
<Text className="mt-1.5 text-xs text-gray-700">全部</Text>
|
||||
</TouchableOpacity>
|
||||
{categories.filter(c => c.type === FinanceType.Expense).map(renderCategoryOption)}
|
||||
</View>
|
||||
{/* 收入 */}
|
||||
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">收入</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{categories.filter(c => c.type === FinanceType.Income).map(renderCategoryOption)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3 pt-2">
|
||||
@@ -525,7 +564,6 @@ export default function Home() {
|
||||
<Button.Label>应用</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -545,33 +583,51 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{showActionMenu && (
|
||||
<Menu
|
||||
presentation="bottom-sheet"
|
||||
isOpen={showActionMenu}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeActionMenu();
|
||||
}}
|
||||
{/* 长按操作菜单 */}
|
||||
<Modal
|
||||
visible={activeBill != null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeActionMenu}
|
||||
>
|
||||
<Pressable
|
||||
className="flex-1 bg-black/30 items-center justify-center"
|
||||
onPress={closeActionMenu}
|
||||
>
|
||||
<Menu.Portal>
|
||||
<Menu.Overlay />
|
||||
<Menu.Content presentation="bottom-sheet">
|
||||
<Menu.Item onPress={handleEditBill}>
|
||||
<PencilIcon size={18} color="#374151" />
|
||||
<Menu.ItemTitle>编辑</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
<Menu.Item onPress={handleDeleteBill} variant="danger">
|
||||
<Trash2Icon size={18} color="#EF4444" />
|
||||
<Menu.ItemTitle>删除</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
<Menu.Item onPress={handleOpenDetail}>
|
||||
<FileTextIcon size={18} color="#374151" />
|
||||
<Menu.ItemTitle>详细</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
<Pressable
|
||||
className="bg-white rounded-xl w-[200px] overflow-hidden"
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 编辑 */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||
activeOpacity={0.6}
|
||||
onPress={handleEditBill}
|
||||
>
|
||||
<PencilIcon size={18} color="#374151" />
|
||||
<Text className="text-[15px] text-gray-800">编辑</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 删除 */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||
activeOpacity={0.6}
|
||||
onPress={handleDeleteBill}
|
||||
>
|
||||
<Trash2Icon size={18} color="#EF4444" />
|
||||
<Text className="text-[15px] text-red-500">删除</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 详细 */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-3 px-4 py-3"
|
||||
activeOpacity={0.6}
|
||||
onPress={handleOpenDetail}
|
||||
>
|
||||
<FileTextIcon size={18} color="#374151" />
|
||||
<Text className="text-[15px] text-gray-800">详细</Text>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
</LayoutView>
|
||||
|
||||
@@ -39,7 +39,7 @@ const CategoryItem = memo(function CategoryItem({
|
||||
isActive ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<FinanceCategoryIcon fileKey={item.icon_file_key} label={item.name} size={28} />
|
||||
<FinanceCategoryIcon fileKey={item.icon_file_key} label={item.name} size={32} />
|
||||
</View>
|
||||
<Text
|
||||
className={`text-[11px] mt-1.5 text-center ${isActive ? "font-semibold text-gray-900" : "text-gray-500"}`}
|
||||
@@ -135,11 +135,7 @@ export default function Add() {
|
||||
}, [isEdit, selectedCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSheetOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
amountInputRef.current?.focus();
|
||||
});
|
||||
} else {
|
||||
if (!isSheetOpen) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}, [isSheetOpen]);
|
||||
@@ -299,59 +295,49 @@ export default function Add() {
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
|
||||
{/* 璁拌处 BottomSheet */}
|
||||
<BottomSheet isOpen={isSheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
{/* 记账 BottomSheet */}
|
||||
<BottomSheet isOpen={isSheetOpen} onOpenChange={handleSheetOpenChange} onOpen={() => amountInputRef.current?.focus()}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content
|
||||
animateOnMount={false}
|
||||
animation="disabled"
|
||||
enableBlurKeyboardOnGesture
|
||||
keyboardBlurBehavior="restore"
|
||||
className="gap-4"
|
||||
>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
{/* 选中的分类 */}
|
||||
{/* 第一行:分类 icon + 名称 | 日期 */}
|
||||
{selectedCategory && (
|
||||
<View className="flex-row items-center gap-3 py-2">
|
||||
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
|
||||
<FinanceCategoryIcon
|
||||
fileKey={selectedCategory.icon_file_key}
|
||||
label={selectedCategory.name}
|
||||
size={22}
|
||||
/>
|
||||
<View className="flex-row items-center justify-between py-1">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
|
||||
<FinanceCategoryIcon
|
||||
fileKey={selectedCategory.icon_file_key}
|
||||
label={selectedCategory.name}
|
||||
size={22}
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-base font-semibold text-gray-900">
|
||||
{selectedCategory.name}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-base font-semibold text-gray-900">
|
||||
{selectedCategory.name}
|
||||
</Text>
|
||||
<PressableFeedback onPress={() => setShowDatePicker(true)}>
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Text className="text-sm text-gray-500">{date}</Text>
|
||||
<ChevronRight size={14} color="#9CA3AF" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 金额 */}
|
||||
<TextField isRequired>
|
||||
<Label>金额</Label>
|
||||
{/* 金额(居中、放大、无 label) */}
|
||||
<View className="items-center overflow-visible">
|
||||
<BottomSheet.Input
|
||||
ref={amountInputRef}
|
||||
placeholder="0.00"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
className="text-2xl font-bold h-14"
|
||||
className="text-4xl font-bold text-center w-full"
|
||||
style={{ minHeight: 64, paddingTop: 8 }}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
{/* 日期 */}
|
||||
<View>
|
||||
<Label>日期</Label>
|
||||
<PressableFeedback onPress={() => setShowDatePicker(true)}>
|
||||
<View className="flex-row items-center justify-between bg-gray-50 rounded-xl px-4 py-3">
|
||||
<Text className="text-sm text-gray-500">选择日期</Text>
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Text className="text-sm font-medium text-gray-800">{date}</Text>
|
||||
<ChevronRight size={16} color="#9CA3AF" />
|
||||
</View>
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
@@ -375,7 +361,6 @@ export default function Add() {
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -352,9 +352,9 @@ export default function FinanceCategoryList() {
|
||||
>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
{form && (
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
<>
|
||||
<View className="items-center py-2">
|
||||
<TouchableOpacity
|
||||
className="w-24 h-24 rounded-3xl bg-gray-100 items-center justify-center"
|
||||
@@ -402,7 +402,7 @@ export default function FinanceCategoryList() {
|
||||
<SaveIcon size={18} color="#fff" />
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
useBottomSheetTimingConfigs,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BottomSheetModalProps } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
@@ -14,261 +7,257 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ComponentProps, ReactElement, ReactNode, Ref } from "react";
|
||||
import { Keyboard, Text, type TextInput, type TextProps } from "react-native";
|
||||
import { Easing } from "react-native-reanimated";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Keyboard,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type TextInput,
|
||||
type TextProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Input, useBottomSheetAwareHandlers } from "heroui-native";
|
||||
|
||||
type AppBottomSheetProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"backdropComponent" | "animationConfigs" | "onDismiss"
|
||||
> & {
|
||||
// ── Props ──────────────────────────────────────────────
|
||||
|
||||
type AppBottomSheetProps = {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onOpen?: () => void;
|
||||
backdropOpacity?: number;
|
||||
animationDuration?: number;
|
||||
backgroundStyle?: ViewStyle;
|
||||
handleIndicatorStyle?: ViewStyle;
|
||||
};
|
||||
|
||||
type AppBottomSheetContentProps = ComponentProps<typeof BottomSheetView> & {
|
||||
animateOnMount?: boolean;
|
||||
animation?: "disabled" | "disable-all" | boolean;
|
||||
backgroundClassName?: string;
|
||||
type AppBottomSheetContentProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentContainerClassName?: string;
|
||||
enableBlurKeyboardOnGesture?: boolean;
|
||||
keyboardBlurBehavior?: AppBottomSheetProps["keyboardBlurBehavior"];
|
||||
animateOnMount?: boolean;
|
||||
animation?: unknown;
|
||||
backgroundClassName?: string;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
type AppBottomSheetContextValue = {
|
||||
isOpen: boolean;
|
||||
animationDuration: number;
|
||||
};
|
||||
type CtxValue = { isOpen: boolean; animationDuration: number; registerInput: (node: TextInput | null) => void };
|
||||
type ContentCtxValue = CtxValue & { shouldAutoFocusSingleInput: boolean };
|
||||
|
||||
type AppBottomSheetContentContextValue = AppBottomSheetContextValue & {
|
||||
shouldAutoFocusSingleInput: boolean;
|
||||
};
|
||||
|
||||
const AppBottomSheetContext = createContext<AppBottomSheetContextValue>({
|
||||
isOpen: false,
|
||||
animationDuration: 300,
|
||||
const AppBottomSheetContext = createContext<CtxValue>({ isOpen: false, animationDuration: 300, registerInput: () => {} });
|
||||
const AppBottomSheetContentContext = createContext<ContentCtxValue>({
|
||||
isOpen: false, animationDuration: 300, registerInput: () => {}, shouldAutoFocusSingleInput: false,
|
||||
});
|
||||
|
||||
const AppBottomSheetContentContext = createContext<AppBottomSheetContentContextValue>({
|
||||
isOpen: false,
|
||||
animationDuration: 300,
|
||||
shouldAutoFocusSingleInput: false,
|
||||
});
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
function setInputRef(ref: Ref<TextInput>, node: TextInput | null) {
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
if (typeof ref === "function") { ref(node); return; }
|
||||
if (ref) { ref.current = node; }
|
||||
}
|
||||
|
||||
function isBottomSheetInputElement(node: ReactNode) {
|
||||
function isSheetInput(node: ReactNode) {
|
||||
if (!isValidElement(node)) return false;
|
||||
|
||||
const type = node.type as { displayName?: string };
|
||||
|
||||
return (
|
||||
typeof type === "object" &&
|
||||
type !== null &&
|
||||
type.displayName === "AppBottomSheetInput"
|
||||
);
|
||||
const t = node.type as { displayName?: string };
|
||||
return typeof t === "object" && t?.displayName === "AppBottomSheetInput";
|
||||
}
|
||||
|
||||
function countBottomSheetInputs(children: ReactNode): number {
|
||||
let count = 0;
|
||||
|
||||
Children.forEach(children, (child) => {
|
||||
function countInputs(children: ReactNode): number {
|
||||
let c = 0;
|
||||
Children.forEach(children, child => {
|
||||
if (!isValidElement(child)) return;
|
||||
|
||||
if (isBottomSheetInputElement(child)) {
|
||||
count += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const element = child as ReactElement<{ children?: ReactNode }>;
|
||||
count += countBottomSheetInputs(element.props.children);
|
||||
if (isSheetInput(child)) { c += 1; return; }
|
||||
c += countInputs((child as ReactElement<{ children?: ReactNode }>).props.children);
|
||||
});
|
||||
|
||||
return count;
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Root ───────────────────────────────────────────────
|
||||
|
||||
const SCREEN_H = Dimensions.get("window").height;
|
||||
|
||||
function AppBottomSheetRoot({
|
||||
children,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
backdropOpacity = 0.3,
|
||||
animationDuration = 300,
|
||||
enablePanDownToClose = true,
|
||||
// interactive: 键盘弹出时 sheet 上移到键盘上方(gorhom 默认值)
|
||||
// extend 只是拉到最高 snap point,不上移 → input 仍被键盘遮挡
|
||||
keyboardBehavior = "interactive",
|
||||
keyboardBlurBehavior = "restore",
|
||||
// adjustPan: 让 gorhom 自己处理键盘避让(gorhom 默认值)
|
||||
// adjustResize 会在 Android 上导致 interactive 模式被跳过(见 BottomSheet.js 源码)
|
||||
android_keyboardInputMode = "adjustPan",
|
||||
enableBlurKeyboardOnGesture = true,
|
||||
onOpen,
|
||||
backdropOpacity = 0.35,
|
||||
animationDuration = 260,
|
||||
backgroundStyle,
|
||||
handleIndicatorStyle,
|
||||
...props
|
||||
}: AppBottomSheetProps) {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const wasPresentedRef = useRef(false);
|
||||
const slideY = useRef(new Animated.Value(SCREEN_H)).current;
|
||||
const backdropFade = useRef(new Animated.Value(0)).current;
|
||||
const keyboardY = useRef(new Animated.Value(0)).current;
|
||||
const panelY = useRef(Animated.add(slideY, keyboardY)).current;
|
||||
|
||||
const animationConfigs = useBottomSheetTimingConfigs({
|
||||
duration: animationDuration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const [visible, setVisible] = useState(false);
|
||||
const animatingRef = useRef(false);
|
||||
const focusedInputRef = useRef<TextInput | null>(null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
onOpenRef.current = onOpen;
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...backdropProps}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={backdropOpacity}
|
||||
pressBehavior="close"
|
||||
/>
|
||||
),
|
||||
[backdropOpacity],
|
||||
);
|
||||
const doAnimateIn = useCallback(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: animationDuration, useNativeDriver: true }),
|
||||
Animated.timing(backdropFade, { toValue: 1, duration: animationDuration, useNativeDriver: true }),
|
||||
]).start(() => {
|
||||
animatingRef.current = false;
|
||||
onOpenRef.current?.();
|
||||
});
|
||||
}, [animationDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = requestAnimationFrame(() => doAnimateIn());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [visible]);
|
||||
|
||||
const doAnimateOut = useCallback((done: () => void) => {
|
||||
Keyboard.dismiss();
|
||||
keyboardY.setValue(0);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: SCREEN_H, duration: animationDuration, useNativeDriver: true }),
|
||||
Animated.timing(backdropFade, { toValue: 0, duration: animationDuration, useNativeDriver: true }),
|
||||
]).start(() => {
|
||||
animatingRef.current = false;
|
||||
setVisible(false);
|
||||
done();
|
||||
});
|
||||
}, [animationDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
wasPresentedRef.current = true;
|
||||
bottomSheetModalRef.current?.present();
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
|
||||
if (wasPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
animatingRef.current = true;
|
||||
slideY.setValue(SCREEN_H);
|
||||
backdropFade.setValue(0);
|
||||
setVisible(true);
|
||||
} else if (visible) {
|
||||
animatingRef.current = true;
|
||||
doAnimateOut(() => onOpenChange?.(false));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
wasPresentedRef.current = false;
|
||||
onOpenChange?.(false);
|
||||
}, [onOpenChange]);
|
||||
const adjustForKeyboard = useCallback((kbHeight: number) => {
|
||||
const input = focusedInputRef.current;
|
||||
if (!input) {
|
||||
keyboardY.setValue(-kbHeight);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
input.measureInWindow((x, y, w, h) => {
|
||||
const screenBottom = SCREEN_H - kbHeight;
|
||||
const inputBottom = y + h;
|
||||
const needed = inputBottom - screenBottom - 16;
|
||||
keyboardY.setValue(needed > 0 ? -needed : 0);
|
||||
});
|
||||
}, 50);
|
||||
}, []);
|
||||
|
||||
const registerInput = useCallback((node: TextInput | null) => {
|
||||
focusedInputRef.current = node;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const s1 = Keyboard.addListener("keyboardWillShow", (e) => adjustForKeyboard(e.endCoordinates.height));
|
||||
const s2 = Keyboard.addListener("keyboardDidShow", (e) => adjustForKeyboard(e.endCoordinates.height));
|
||||
const h1 = Keyboard.addListener("keyboardWillHide", () => keyboardY.setValue(0));
|
||||
const h2 = Keyboard.addListener("keyboardDidHide", () => keyboardY.setValue(0));
|
||||
return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); };
|
||||
}, [visible, adjustForKeyboard]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<AppBottomSheetContext.Provider value={{ isOpen, animationDuration }}>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enablePanDownToClose={enablePanDownToClose}
|
||||
keyboardBehavior={keyboardBehavior}
|
||||
keyboardBlurBehavior={keyboardBlurBehavior}
|
||||
android_keyboardInputMode={android_keyboardInputMode}
|
||||
enableBlurKeyboardOnGesture={enableBlurKeyboardOnGesture}
|
||||
backdropComponent={renderBackdrop}
|
||||
animationConfigs={animationConfigs}
|
||||
backgroundStyle={[{ backgroundColor: "#fff" }, backgroundStyle]}
|
||||
handleIndicatorStyle={[{ backgroundColor: "#d1d5db" }, handleIndicatorStyle]}
|
||||
onDismiss={handleDismiss}
|
||||
{...props}
|
||||
<AppBottomSheetContext.Provider value={{ isOpen, animationDuration, registerInput }}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={() => onOpenChange?.(false)}
|
||||
statusBarTranslucent
|
||||
>
|
||||
{children}
|
||||
</BottomSheetModal>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => onOpenChange?.(false)}>
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ backgroundColor: "#000", opacity: Animated.multiply(backdropFade, backdropOpacity) },
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0, right: 0, bottom: 0,
|
||||
transform: [{ translateY: panelY }],
|
||||
}}
|
||||
>
|
||||
<View style={[{ backgroundColor: "#fff", borderTopLeftRadius: 16, borderTopRightRadius: 16 }, backgroundStyle]}>
|
||||
<View className="items-center pt-3 pb-2" style={handleIndicatorStyle}>
|
||||
<View className="w-9 h-1 rounded-full bg-gray-300" />
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</AppBottomSheetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Portal / Overlay ───────────────────────────────────
|
||||
|
||||
function AppBottomSheetPortal({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppBottomSheetOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppBottomSheetContent({
|
||||
children,
|
||||
className,
|
||||
contentContainerClassName,
|
||||
animateOnMount: _animateOnMount,
|
||||
animation: _animation,
|
||||
backgroundClassName: _backgroundClassName,
|
||||
enableBlurKeyboardOnGesture: _enableBlurKeyboardOnGesture,
|
||||
keyboardBlurBehavior: _keyboardBlurBehavior,
|
||||
style,
|
||||
...props
|
||||
}: AppBottomSheetContentProps) {
|
||||
const context = useContext(AppBottomSheetContext);
|
||||
const inputCount = countBottomSheetInputs(children);
|
||||
// ── Content ────────────────────────────────────────────
|
||||
|
||||
function AppBottomSheetContent({
|
||||
children, className, contentContainerClassName, style, ...props
|
||||
}: AppBottomSheetContentProps) {
|
||||
const ctx = useContext(AppBottomSheetContext);
|
||||
const inputCount = countInputs(children);
|
||||
return (
|
||||
<AppBottomSheetContentContext.Provider
|
||||
value={{
|
||||
...context,
|
||||
shouldAutoFocusSingleInput: inputCount === 1,
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
className={contentContainerClassName ?? className}
|
||||
style={[{ paddingHorizontal: 16 }, style]}
|
||||
{...props}
|
||||
<AppBottomSheetContentContext.Provider value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}>
|
||||
<View
|
||||
className={contentContainerClassName ?? className ?? "px-4 py-4"}
|
||||
style={[{ paddingTop:16,paddingBottom: 28,paddingLeft:24,paddingRight:24 }, style]}
|
||||
{...(props as Record<string, unknown>)}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
</View>
|
||||
</AppBottomSheetContentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBottomSheetTitle(props: TextProps) {
|
||||
return <Text {...props} />;
|
||||
}
|
||||
|
||||
function AppBottomSheetDescription(props: TextProps) {
|
||||
return <Text {...props} />;
|
||||
}
|
||||
// ── Input ──────────────────────────────────────────────
|
||||
|
||||
const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
|
||||
({ onFocus, onBlur, ...props }, ref) => {
|
||||
// 用 heroui 原生 Input + useBottomSheetAwareHandlers 接入 sheet 键盘状态。
|
||||
// hook 内部调用 gorhom 的 useBottomSheetInternal(true)(unsafe 模式),
|
||||
// 在 sheet context 外返回 no-op handlers,在 sheet 内则把 input 的 focus/blur
|
||||
// 注册到 animatedKeyboardState,配合 keyboardBehavior="interactive" 触发上移避让。
|
||||
// 这样无需用 BottomSheetTextInput,保留 heroui Input 的全部样式与 API。
|
||||
const handlers = useBottomSheetAwareHandlers();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const { isOpen, animationDuration, shouldAutoFocusSingleInput } = useContext(
|
||||
AppBottomSheetContentContext,
|
||||
);
|
||||
const handleRef = useCallback(
|
||||
(node: TextInput | null) => {
|
||||
inputRef.current = node;
|
||||
setInputRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !shouldAutoFocusSingleInput) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, animationDuration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [animationDuration, isOpen, shouldAutoFocusSingleInput]);
|
||||
const { registerInput } = useContext(AppBottomSheetContentContext);
|
||||
const handleRef = useCallback((node: TextInput | null) => { inputRef.current = node; setInputRef(ref, node); }, [ref]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={handleRef}
|
||||
onFocus={(e) => {
|
||||
onFocus={e => {
|
||||
handlers.onFocus(e);
|
||||
registerInput(inputRef.current);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur={e => {
|
||||
handlers.onBlur(e);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
@@ -277,16 +266,18 @@ const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AppBottomSheetInput.displayName = "AppBottomSheetInput";
|
||||
|
||||
const AppBottomSheet = Object.assign(AppBottomSheetRoot, {
|
||||
function AppBottomSheetTitle(props: TextProps) { return <Text {...props} />; }
|
||||
function AppBottomSheetDescription(props: TextProps) { return <Text {...props} />; }
|
||||
|
||||
const BottomSheet = Object.assign(AppBottomSheetRoot, {
|
||||
Portal: AppBottomSheetPortal,
|
||||
Overlay: AppBottomSheetOverlay,
|
||||
Content: AppBottomSheetContent,
|
||||
Input: AppBottomSheetInput,
|
||||
Title: AppBottomSheetTitle,
|
||||
Description: AppBottomSheetDescription,
|
||||
Input: AppBottomSheetInput,
|
||||
});
|
||||
|
||||
export default AppBottomSheet;
|
||||
export default BottomSheet;
|
||||
@@ -63,8 +63,7 @@ export default function DateTime({
|
||||
}
|
||||
}, [show, value]);
|
||||
|
||||
// 关闭:BottomSheet 的 onOpenChange(false) 或 confirm 后 setOpen(false)
|
||||
// 立刻通知父组件,不延迟 —— BottomSheet 自身驱动关闭动画
|
||||
// 关闭
|
||||
useEffect(() => {
|
||||
if (!open && show) {
|
||||
onCloseRef.current?.();
|
||||
@@ -87,35 +86,36 @@ export default function DateTime({
|
||||
}, [loading, tempDate, finalFormat, onConfirm]);
|
||||
|
||||
return (
|
||||
<BottomSheet isOpen={open} onOpenChange={setOpen}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="w-full items-center">
|
||||
<DateTimePicker
|
||||
mode="single"
|
||||
date={tempDate}
|
||||
initialView={modeViewMap[mode]}
|
||||
timePicker={mode === "time" || mode === "datetime"}
|
||||
selectedItemColor={accentColor}
|
||||
onChange={({ date }) => {
|
||||
if (date) setTempDate(dayjs(date));
|
||||
}}
|
||||
/>
|
||||
<BottomSheet
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
backdropOpacity={0.35}
|
||||
>
|
||||
<BottomSheet.Content className="pb-6">
|
||||
<View className="w-full items-center">
|
||||
<DateTimePicker
|
||||
mode="single"
|
||||
date={tempDate}
|
||||
initialView={modeViewMap[mode]}
|
||||
timePicker={mode === "time" || mode === "datetime"}
|
||||
selectedItemColor={accentColor}
|
||||
onChange={({ date }) => {
|
||||
if (date) setTempDate(dayjs(date));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button className="w-full" onPress={handleConfirm} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
<Button className="w-full" onPress={handleConfirm} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import DateTimePicker, { DateType } from "react-native-dates-picker";
|
||||
import dayjs from "dayjs";
|
||||
import { Button, Spinner, useThemeColor } from "heroui-native";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import { View } from "react-native";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { CheckIcon } from "lucide-react-native";
|
||||
import { FadeIn } from "react-native-reanimated";
|
||||
|
||||
@@ -69,37 +69,38 @@ export default function DateTimeRange({
|
||||
}, [loading, range, onConfirm]);
|
||||
|
||||
return (
|
||||
<BottomSheet isOpen={open} onOpenChange={setOpen}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false} backgroundClassName="bg-white">
|
||||
<View className="w-full items-center">
|
||||
<DateTimePicker
|
||||
mode="range"
|
||||
startDate={range.startDate}
|
||||
endDate={range.endDate}
|
||||
selectedItemColor={accentColor}
|
||||
selectedRangeBackgroundColor={accentSoftColor}
|
||||
onChange={(params) => setRange(params)}
|
||||
/>
|
||||
<BottomSheet
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
backdropOpacity={0.35}
|
||||
>
|
||||
<BottomSheet.Content className="pb-6">
|
||||
<View className="w-full items-center">
|
||||
<DateTimePicker
|
||||
mode="range"
|
||||
startDate={range.startDate}
|
||||
endDate={range.endDate}
|
||||
selectedItemColor={accentColor}
|
||||
selectedRangeBackgroundColor={accentSoftColor}
|
||||
onChange={(params) => setRange(params)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onPress={handleConfirm}
|
||||
isDisabled={loading || !range.startDate}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
<Button
|
||||
className="w-full"
|
||||
onPress={handleConfirm}
|
||||
isDisabled={loading || !range.startDate}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon color="#fff" />
|
||||
<Button.Label>确认</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ interface FinanceCategoryIconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BG = "#F3F4F6";
|
||||
|
||||
function FinanceCategoryIcon({ fileKey, label, size = 28 }: FinanceCategoryIconProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const uri = useMemo(() => buildFileUrl(fileKey), [fileKey]);
|
||||
@@ -26,7 +24,6 @@ function FinanceCategoryIcon({ fileKey, label, size = 28 }: FinanceCategoryIconP
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
backgroundColor: DEFAULT_BG,
|
||||
}}
|
||||
>
|
||||
{showImage ? (
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface FinanceBill {
|
||||
category_type: FinanceType;
|
||||
category_icon_file_key: string;
|
||||
member_nickname?: string;
|
||||
member_avatar?: string;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
update_by?: number;
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
// NOTE: This file is generated by uniwind and it should not be edited manually.
|
||||
/// <reference types="uniwind/types" />
|
||||
|
||||
declare module "uniwind" {
|
||||
export interface UniwindConfig {
|
||||
themes: readonly ["light", "dark"];
|
||||
}
|
||||
declare module 'uniwind' {
|
||||
export interface UniwindConfig {
|
||||
themes: readonly ['light', 'dark']
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export {}
|
||||
Reference in new issue
Block a user