Skip to content

Commit cb27eb5

Browse files
committed
fix(feed): 纯图片碎片放行与图片 base64 传输
1 parent 6efd66c commit cb27eb5

5 files changed

Lines changed: 68 additions & 15 deletions

File tree

‎app/src-tauri/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/src-tauri/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ zip = "2"
7878
# arboard 3.x 在 Windows 用 clipboard-win 实现,无额外系统依赖
7979
arboard = "3"
8080
fsrs = { version = "6.6.1", default-features = false }
81+
# v0.11.1 审查修复:碎片图片 IPC 传输改 base64(原数字数组序列化 10MB 图≈35MB JSON,
82+
# 剪贴板捕获卡顿);版本已在依赖树锁内(零新增下载)
83+
base64 = "0.22"
8184

8285
[dev-dependencies]
8386
tempfile = "3"

‎app/src-tauri/src/commands_fragments.rs‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,19 @@ pub fn set_feature_flag(
4545
Ok(value)
4646
}
4747

48-
/// 碎片快速捕获(feed 进料口;文本 + 可选图片字节)。
48+
/// 碎片快速捕获(feed 进料口;文本 + 可选 base64 图片)。
4949
///
50-
/// @ai-context: 流程——开关准入 → 文本校验 → 图片解码验证落盘 →
50+
/// @ai-context: 流程——开关准入 → 文本/图片至少一项 → 图片解码验证落盘 →
5151
/// DomainTag 检测 → feed 主题组查/建 → 碎片落库。
5252
/// 图片失败不阻断文本捕获(诚实降级:纯文本碎片)。
53+
/// @ai-context: 审查修复(2026-08-22):① 图片改 base64 传输——原 Vec<u8>
54+
/// 数字数组序列化 10MB 图≈35MB JSON,剪贴板捕获卡顿;② 纯图片
55+
/// 捕获不再被空文本检查拒绝(text 占位"(图片碎片)"保列表可读)。
5356
#[tauri::command]
5457
pub async fn capture_fragment(
5558
state: State<'_, AppState>,
5659
text: String,
57-
image_bytes: Option<Vec<u8>>,
60+
image_b64: Option<String>,
5861
source: Option<String>,
5962
) -> Result<Fragment, String> {
6063
// 开关准入(后端不信前端隐藏——v4 §11.3 默认关纪律)
@@ -65,17 +68,18 @@ pub async fn capture_fragment(
6568
}
6669
}
6770
let text: String = text.trim().chars().take(FRAGMENT_TEXT_MAX).collect();
68-
if text.is_empty() {
69-
return Err("碎片内容不能为空".to_string());
71+
let has_image = image_b64.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false);
72+
if text.is_empty() && !has_image {
73+
return Err("碎片内容不能为空(文本或图片至少一项)".to_string());
7074
}
7175
let source = match source.as_deref() {
7276
Some("clipboard") => "clipboard".to_string(),
7377
_ => "manual".to_string(),
7478
};
75-
// 图片:解码验证 + 统一转 PNG 落盘(白名单格式由解码器把关)
76-
let image_path = match image_bytes {
77-
Some(bytes) if !bytes.is_empty() => {
78-
match save_fragment_image(&state.data_dir, &bytes) {
79+
// 图片:base64 解码 + 解码器验证 + 统一转 PNG 落盘(白名单格式由解码器把关)
80+
let image_path = match image_b64 {
81+
Some(b64) if !b64.trim().is_empty() => {
82+
match decode_and_save_fragment_image(&state.data_dir, &b64) {
7983
Ok(rel) => Some(rel),
8084
Err(e) => {
8185
// 图片失败不阻断文本捕获(降级为纯文本碎片,留日志线索)
@@ -86,6 +90,8 @@ pub async fn capture_fragment(
8690
}
8791
_ => None,
8892
};
93+
// 纯图片碎片:text 占位保列表/卡片可读(NOT NULL 约束 + UI 诚实呈现)
94+
let text = if text.is_empty() { "(图片碎片)".to_string() } else { text };
8995
// DomainTag 自动归组(detect_domain 标题词路径——碎片文本即判据源)
9096
let detection = detect_domain(&DomainSignals {
9197
title: Some(text.clone()),
@@ -138,11 +144,22 @@ pub fn list_group_fragments(state: State<'_, AppState>, group_id: i64) -> Result
138144
state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())
139145
}
140146

141-
/// 解码验证并落盘碎片图片(返回 fragments/ 下相对路径)。
147+
/// base64 解码 + 解码验证并落盘碎片图片(返回 fragments/ 下相对路径)。
142148
///
143149
/// @ai-context: image::load_from_memory 把关格式白名单(png/jpeg/webp/gif/bmp…
144150
/// 解码器支持集);统一转 PNG 存储(前端展示口径单一);
145-
/// 超限字节数前置拒绝(解码前,省 CPU)。
151+
/// base64 解码失败前置拒绝(可诊断错误,不落盘垃圾)。
152+
fn decode_and_save_fragment_image(data_dir: &std::path::Path, b64: &str) -> Result<String, String> {
153+
use base64::Engine;
154+
let bytes = base64::engine::general_purpose::STANDARD
155+
.decode(b64.trim())
156+
.map_err(|e| format!("图片 base64 解码失败: {}", e))?;
157+
save_fragment_image(data_dir, &bytes)
158+
}
159+
160+
/// 解码验证并落盘碎片图片(字节入口;返回 fragments/ 下相对路径)。
161+
///
162+
/// @ai-context: 超限字节数前置拒绝(解码前,省 CPU)。
146163
fn save_fragment_image(data_dir: &std::path::Path, bytes: &[u8]) -> Result<String, String> {
147164
if bytes.len() > FRAGMENT_IMAGE_MAX_BYTES {
148165
return Err(format!("图片超限(上限 {} MB)", FRAGMENT_IMAGE_MAX_BYTES / 1024 / 1024));

‎app/src/components/NoteGroupPanel.tsx‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ interface Props {
5959
onOpenReview: (groupId: number | null, groupName: string) => void;
6060
}
6161

62+
/** Blob → base64(分块转换——大截图防 String.fromCharCode 栈溢出) */
63+
async function blobToBase64(blob: Blob): Promise<string> {
64+
const buf = new Uint8Array(await blob.arrayBuffer());
65+
let bin = "";
66+
const CHUNK = 0x8000;
67+
for (let i = 0; i < buf.length; i += CHUNK) {
68+
bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
69+
}
70+
return btoa(bin);
71+
}
72+
6273
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged, onOpenReview }: Props) {
6374
const [groups, setGroups] = useState<NoteGroup[]>([]);
6475
const [expandedId, setExpandedId] = useState<number | null>(null);
@@ -94,10 +105,12 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
94105
}, [load]);
95106

96107
// ── 碎片捕获(feed 进料口;后端二次校验开关)──
108+
// 审查修复(2026-08-22):图片改 base64 传输(原数字数组序列化 10MB 图≈35MB
109+
// JSON 卡顿);分块 btoa 防大图一次性转换栈溢出。
97110
const runCapture = async (source: "manual" | "clipboard") => {
98111
try {
99112
let text = fragText.trim();
100-
let imageBytes: number[] | null = null;
113+
let imageB64: string | null = null;
101114
if (source === "clipboard") {
102115
// 剪贴板优先:文本+图片一次捕获(REQ-132 预留兑现)
103116
const clipText = await navigator.clipboard.readText().catch(() => "");
@@ -107,16 +120,16 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
107120
const imgType = item.types.find((t) => t.startsWith("image/"));
108121
if (imgType) {
109122
const blob = await item.getType(imgType);
110-
imageBytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
123+
imageB64 = await blobToBase64(blob);
111124
break;
112125
}
113126
}
114127
}
115-
if (!text && !imageBytes) {
128+
if (!text && !imageB64) {
116129
setStatus("请输入碎片内容或复制剪贴板后再捕获");
117130
return;
118131
}
119-
await invoke("capture_fragment", { text, imageBytes, source });
132+
await invoke("capture_fragment", { text, imageB64, source });
120133
setFragText("");
121134
setStatus("");
122135
await load(); // 可能新建 feed 主题组——刷新组列表

‎app/src/types/notes.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ export interface NoteGroup {
106106
updatedAt: number;
107107
}
108108

109+
// ────────────────────────────────────────────────────────────
110+
// 碎片类型(v0.11.1 feed 原料层;Rust Fragment camelCase 契约)
111+
// ────────────────────────────────────────────────────────────
112+
113+
/** 碎片(feed 地形原料;不是笔记,身份诚实——v4 契约) */
114+
export interface Fragment {
115+
id: number;
116+
text: string;
117+
/** 图片相对路径(fragments/ 下;null=纯文本碎片) */
118+
imagePath: string | null;
119+
domainTag: string | null;
120+
groupId: number | null;
121+
/** manual / clipboard */
122+
source: string;
123+
/** active / archived(v0.11.3 结算归档) */
124+
status: string;
125+
createdAt: number;
126+
}
127+
109128
// ────────────────────────────────────────────────────────────
110129
// 闪卡类型(v0.11.2 学习循环统一;Rust Flashcard camelCase 契约)
111130
// ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)