Skip to content

Commit 6e1c2c4

Browse files
committed
feat(notes): 分组树/整列折叠与全站列自适应(v0.15 ②③)
1 parent f75a850 commit 6e1c2c4

19 files changed

Lines changed: 876 additions & 177 deletions

‎app/src/components/ColumnBar.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* ColumnBar — 折叠列的窄条形态(v0.15:组侧栏/列表/大纲整列可收起)。
3+
*
4+
* @ai-context: 折叠 ≠ 删除——26px 图标条保留入口(点击展开恢复记忆宽度);
5+
* 窄窗自动折叠与手动折叠共用本组件(useColumnLayout.folded 驱动)。
6+
*/
7+
interface Props {
8+
icon: string;
9+
title: string;
10+
onClick: () => void;
11+
/** 附加徽标(如收件箱计数)——窄条上不常显,仅 title 提示 */
12+
badge?: string;
13+
}
14+
15+
export default function ColumnBar({ icon, title, onClick, badge }: Props) {
16+
return (
17+
<div
18+
data-testid="column-bar"
19+
onClick={onClick}
20+
title={`${title}${badge ? `(${badge})` : ""}`}
21+
style={{
22+
width: 26,
23+
flexShrink: 0,
24+
borderRight: "1px solid #e5e7eb",
25+
display: "flex",
26+
flexDirection: "column",
27+
alignItems: "center",
28+
paddingTop: 10,
29+
gap: 8,
30+
cursor: "pointer",
31+
background: "#fafafa",
32+
userSelect: "none",
33+
fontSize: 14,
34+
}}
35+
>
36+
<span style={{ writingMode: "vertical-rl", fontSize: 11, color: "#6b7280", letterSpacing: 3 }}>
37+
{icon} {title}
38+
</span>
39+
</div>
40+
);
41+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* ColumnResizer — 列拖拽手柄(v0.15 全站自适应)。
3+
*
4+
* @ai-context: pointer 事件捕获(setPointerCapture → window move/up)——拖出窗口
5+
* 或丢失 mouseup 仍能结束;方向由调用方决定(side="right" 拖右边
6+
* 增宽/左边收窄;side="left" 反向——拖左手柄收窄列本身)。纯展示,
7+
* 增量化经 onResize 上抛(useColumnLayout.resizeBy 夹取 min/max)。
8+
*/
9+
import { useState } from "react";
10+
11+
interface Props {
12+
/** 手柄所在边的对齐方向(决定拖拽增量的符号) */
13+
side?: "right" | "left";
14+
onResize: (delta: number) => void;
15+
/** 双击恢复默认宽度(调用方负责 resetWidth) */
16+
onReset?: () => void;
17+
}
18+
19+
export default function ColumnResizer({ side = "right", onResize, onReset }: Props) {
20+
const [hover, setHover] = useState(false);
21+
22+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
23+
e.preventDefault();
24+
const startX = e.clientX;
25+
const el = e.currentTarget;
26+
try { el.setPointerCapture(e.pointerId); } catch { /* 旧 WebView 无捕获——move 仍走 window */ }
27+
const move = (ev: PointerEvent) => {
28+
const dx = ev.clientX - startX;
29+
onResize(side === "right" ? dx : -dx);
30+
};
31+
const up = () => {
32+
window.removeEventListener("pointermove", move);
33+
window.removeEventListener("pointerup", up);
34+
};
35+
window.addEventListener("pointermove", move);
36+
window.addEventListener("pointerup", up);
37+
};
38+
39+
return (
40+
<div
41+
data-testid="column-resizer"
42+
onPointerDown={onPointerDown}
43+
onPointerEnter={() => setHover(true)}
44+
onPointerLeave={() => setHover(false)}
45+
onDoubleClick={() => onReset?.()}
46+
title="拖拽调整宽度(双击恢复默认)"
47+
style={{
48+
width: 5,
49+
flexShrink: 0,
50+
cursor: "col-resize",
51+
background: hover ? "#0d9488" : "transparent",
52+
borderLeft: "1px solid #e5e7eb",
53+
transition: "background 0.15s",
54+
userSelect: "none",
55+
}}
56+
/>
57+
);
58+
}

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ import type { Fragment, Note, NoteGroup } from "../types";
1616
import { fragmentPreview, promoteTitleFor } from "../utils/inbox";
1717

1818
interface Props {
19+
/** 列宽(v0.15 全站自适应——父层 useColumnLayout 驱动;缺省 320=历史值) */
20+
width?: number;
1921
/** 碎片列表变更(捕获/升降/删除)后刷新回调——父层重载笔记与计数 */
2022
onChanged: () => void;
2123
/** 升笔记成功——父层打开新笔记(右侧自动打开,闭环可见) */
2224
onPromoted: (note: Note) => void;
25+
/** v0.15:折叠为窄条(父层 useColumnLayout.setManualFolded(true)) */
26+
onCollapse?: () => void;
2327
}
2428

2529
/** 碎片图片缩略(后端 resolve 校验 → asset 协议;无图/失败降级文本) */
@@ -63,7 +67,7 @@ interface PromoteForm {
6367
groupId: string;
6468
}
6569

66-
export default function FeedFragmentList({ onChanged, onPromoted }: Props) {
70+
export default function FeedFragmentList({ width = 320, onChanged, onPromoted, onCollapse }: Props) {
6771
const [fragments, setFragments] = useState<Fragment[]>([]);
6872
const [loaded, setLoaded] = useState(false);
6973
const [err, setErr] = useState("");
@@ -164,9 +168,10 @@ export default function FeedFragmentList({ onChanged, onPromoted }: Props) {
164168
};
165169

166170
return (
167-
<div style={{ width: 320, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0 }}>
168-
<div style={{ padding: "10px 14px", borderBottom: "1px solid #e5e7eb", fontWeight: 600 }}>
171+
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0 }}>
172+
<div style={{ padding: "10px 14px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
169173
📥 收件箱 <span style={{ fontWeight: 400, fontSize: 11, color: "#9ca3af" }}>碎片({fragments.length})· 原料层</span>
174+
<button onClick={onCollapse} style={{ marginLeft: "auto", fontSize: 12, cursor: "pointer", border: "none", background: "none", color: "#9ca3af" }} title="折叠收件箱">⟨</button>
170175
</div>
171176

172177
<div style={{ flex: 1, overflowY: "auto", padding: 8 }}>

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ import {
2626
import RouteInfoPopover from "./RouteInfoPopover";
2727
import GroupSidebarRow from "./GroupSidebarRow";
2828
import GroupCreateDialog from "./GroupCreateDialog";
29+
import { blobToBase64 } from "../utils/blobToBase64";
2930

3031
interface Props {
32+
/** 列宽(v0.15 全站自适应——父层 useColumnLayout 驱动;缺省 240=历史值) */
33+
width?: number;
3134
/** 当前过滤组(null=全部笔记) */
3235
groupFilter: number | null;
3336
onGroupFilterChange: (id: number | null) => void;
@@ -45,22 +48,13 @@ interface Props {
4548
refreshToken: number;
4649
/** 跳转体系页并选中体系(v0.13.7 触点①) */
4750
onOpenSystem: (systemId: number) => void;
48-
}
49-
50-
/** Blob → base64(分块转换——大截图防 String.fromCharCode 栈溢出) */
51-
async function blobToBase64(blob: Blob): Promise<string> {
52-
const buf = new Uint8Array(await blob.arrayBuffer());
53-
let bin = "";
54-
const CHUNK = 0x8000;
55-
for (let i = 0; i < buf.length; i += CHUNK) {
56-
bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
57-
}
58-
return btoa(bin);
51+
/** v0.15:折叠为窄条(父层 useColumnLayout.setManualFolded(true)) */
52+
onCollapse?: () => void;
5953
}
6054

6155
export default function GroupSidebar({
62-
groupFilter, onGroupFilterChange, onChanged, onOpenReview, selectedNoteId,
63-
onOpenInbox, inboxActive, refreshToken, onOpenSystem,
56+
width = 240, groupFilter, onGroupFilterChange, onChanged, onOpenReview, selectedNoteId,
57+
onOpenInbox, inboxActive, refreshToken, onOpenSystem, onCollapse,
6458
}: Props) {
6559
const [groups, setGroups] = useState<NoteGroup[]>([]);
6660
const [feedCaptureOn, setFeedCaptureOn] = useState(true);
@@ -267,7 +261,7 @@ export default function GroupSidebar({
267261
const filtering = groupQuery.trim().length > 0;
268262

269263
return (
270-
<div style={{ width: 240, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", position: "relative", minWidth: 0 }}>
264+
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", position: "relative", minWidth: 0 }}>
271265
{/* 头部:标题 + 新建组 + 刷新 */}
272266
<div style={{ padding: "10px 12px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
273267
<span>📁 笔记组</span>
@@ -280,6 +274,7 @@ export default function GroupSidebar({
280274
+ 新建组
281275
</button>
282276
<button onClick={() => void load()} style={{ fontSize: 13, cursor: "pointer" }} title="刷新组列表">⟳</button>
277+
<button onClick={onCollapse} style={{ fontSize: 12, cursor: "pointer", border: "none", background: "none", color: "#9ca3af" }} title="折叠侧栏">⟨</button>
283278
</div>
284279

285280
<div style={{ flex: 1, overflowY: "auto", padding: 6, display: "flex", flexDirection: "column", gap: 2 }}>

‎app/src/components/KnowledgeDetailPanel.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import KnowledgeDecisionForm from "./KnowledgeDecisionForm";
2222
import KnowledgeDecisionLog from "./KnowledgeDecisionLog";
2323

2424
interface Props {
25+
/** 面板宽(v0.15 全站自适应——父层 useColumnLayout 驱动;缺省 320=历史值) */
26+
width?: number;
2527
system: KnowledgeSystem;
2628
nodes: KnowledgeNode[];
2729
concepts: KnowledgeConcept[];
@@ -36,7 +38,7 @@ const CONCEPT_STATUSES: KnowledgeConceptStatus[] = ["core", "watching", "archive
3638
/** 模型状态选项(active/watching/archived) */
3739
const MODEL_STATUSES: KnowledgeModelStatus[] = ["active", "watching", "archived"];
3840

39-
export default function KnowledgeDetailPanel({ system, nodes, concepts, models, links, selection, onChanged }: Props) {
41+
export default function KnowledgeDetailPanel({ width = 320, system, nodes, concepts, models, links, selection, onChanged }: Props) {
4042
const [collapsed, setCollapsed] = useState(false);
4143
const [decisionFormOpen, setDecisionFormOpen] = useState(false);
4244
const [saving, setSaving] = useState(false);
@@ -129,7 +131,7 @@ export default function KnowledgeDetailPanel({ system, nodes, concepts, models,
129131
const selectedConcept = selection?.type === "concept" ? concepts.find((c) => c.id === selection.id) ?? null : null;
130132

131133
return (
132-
<div data-testid="detail-panel" style={{ width: 320, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0, background: "#fff" }}>
134+
<div data-testid="detail-panel" style={{ width, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0, background: "#fff" }}>
133135
<div style={{ display: "flex", alignItems: "center", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>
134136
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>详情</span>
135137
<button data-testid="detail-collapse" onClick={() => setCollapsed(true)} title="折叠详情面板" style={{ marginLeft: "auto", border: "none", background: "none", cursor: "pointer", fontSize: 14, color: "#9ca3af" }}>

‎app/src/components/NoteImage.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ export default function NoteImage({ src, alt = "", noteId, onOpen }: Props) {
8080
loading="lazy"
8181
onClick={() => onOpen?.(url, alt)}
8282
style={{
83-
// 当前为 CSS 缩略图:全尺寸图片加载后 CSS 缩放为 240×160 cover(容器约束保留)。
84-
// TODO(v0.12.0): 使用后端 thumbs/ 缩略图路径替代全尺寸 CSS 缩放
83+
// v0.15:尺寸自适应——按原始比例缩放(约 60vh 上限;窄窗 maxWidth 100% 不溢出)
8584
maxWidth: "100%",
86-
width: 240,
87-
height: 160,
88-
objectFit: "cover",
85+
maxHeight: "60vh",
86+
width: "auto",
87+
height: "auto",
88+
display: "block",
8989
borderRadius: 6,
9090
border: "1px solid #e5e7eb",
9191
cursor: onOpen ? "zoom-in" : "default",

‎app/src/components/NoteListRow.tsx‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* NoteListRow — 笔记列表行(v0.15 自 NoteListView 拆出——树态/平铺共用)。
3+
*
4+
* @ai-context: 行 id=`note-row-{id}` 供 focusNoteId 跨页直达滚动定位;
5+
* 拖拽源(组行为 drop target,move_note_to_group 命令);
6+
* 勾选/固定/标签/来源会话跳转保持 v0.12.x 语义不变。
7+
*/
8+
import { useMemo } from "react";
9+
import type { Note } from "../types";
10+
import { paletteHex } from "../utils/colorPalette";
11+
import type { ThemeMode } from "../utils/colorPalette";
12+
import { fmtDate, parseTags } from "../utils/noteHelpers";
13+
14+
interface Props {
15+
note: Note;
16+
/** 行左侧色条(父层 resolveNoteColor 解析结果) */
17+
accent: string;
18+
selectedId: number | null;
19+
checked: boolean;
20+
tagColors?: Record<string, string>;
21+
onSelect: (note: Note) => void;
22+
onToggleSelect: (id: number) => void;
23+
onOpenSession: (sessionId: number) => void;
24+
}
25+
26+
export default function NoteListRow({
27+
note, accent, selectedId, checked, tagColors, onSelect, onToggleSelect, onOpenSession,
28+
}: Props) {
29+
const tags = parseTags(note);
30+
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
31+
const theme: ThemeMode = useMemo(
32+
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
33+
[],
34+
);
35+
return (
36+
<div
37+
id={`note-row-${note.id}`}
38+
draggable
39+
onDragStart={(e) => {
40+
// v0.14 C1:拖拽归组——笔记卡片为 drag source(组行为 drop target)
41+
e.dataTransfer.setData("text/note-id", String(note.id));
42+
e.dataTransfer.effectAllowed = "move";
43+
}}
44+
onClick={() => onSelect(note)}
45+
style={{
46+
padding: "10px 14px",
47+
borderBottom: "1px solid #f3f4f6",
48+
borderLeft: `4px solid ${accent}`,
49+
cursor: "pointer",
50+
background: selectedId === note.id ? "#f0fdfa" : "transparent",
51+
}}
52+
>
53+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
54+
<input
55+
type="checkbox"
56+
checked={checked}
57+
onClick={(e) => e.stopPropagation()}
58+
onChange={() => onToggleSelect(note.id)}
59+
style={{ cursor: "pointer", flexShrink: 0 }}
60+
title="勾选后可批量删除"
61+
/>
62+
{note.pin ? <span style={{ fontSize: 11, color: "#b45309" }}>📌</span> : null}
63+
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1 }}>
64+
{note.title}
65+
</div>
66+
</div>
67+
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 4 }}>
68+
<span>{note.source === "classroom" ? "📡 课堂" : "✍ 手动"} · {fmtDate(note.updated_at)}</span>
69+
{tags.slice(0, 3).map((t) => (
70+
<span
71+
key={t}
72+
style={{
73+
fontSize: 10, color: "#6b7280", borderRadius: 8, padding: "0 4px",
74+
// v0.14 B:标签色徽标底色(tagColors 命中用色板色 13% 透明底;否则默认灰底)
75+
background: tagColors?.[t] ? `${paletteHex(tagColors[t], theme)}22` : "#f3f4f6",
76+
}}
77+
>
78+
{t}
79+
</span>
80+
))}
81+
{tags.length > 3 && <span style={{ fontSize: 10, color: "#9ca3af" }}>+{tags.length - 3}</span>}
82+
{note.session_id != null && (
83+
<span
84+
onClick={(e) => { e.stopPropagation(); onOpenSession(note.session_id as number); }}
85+
style={{ fontSize: 11, color: "#0f766e", cursor: "pointer", fontWeight: 600 }}
86+
title="跳转到来源会话"
87+
>
88+
来源会话 →
89+
</span>
90+
)}
91+
</div>
92+
</div>
93+
);
94+
}

0 commit comments

Comments
 (0)