Skip to content

Commit efd2fb4

Browse files
committed
feat(sessions): 图文会话列表徽标与详情空态 + 坐标换算纯函数测试
1 parent 1aee3fc commit efd2fb4

5 files changed

Lines changed: 79 additions & 9 deletions

File tree

‎app/src/components/ScreenSelectOverlay.tsx‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* 不经 window.devicePixelRatio(显示缩放已含在 letterbox scale)。
1010
*/
1111
import { useRef, useState } from "react";
12+
import { normToPixels } from "../utils/photoCrop";
1213

1314
interface Props {
1415
/** 全屏快照 data URL(JPEG;capture_screen_snapshot 的 base64 拼前缀) */
@@ -84,21 +85,18 @@ export default function ScreenSelectOverlay({ src, imageWidth, imageHeight, savi
8485
setConfirm(box);
8586
};
8687

87-
/** canvas 物理像素裁剪 → PNG base64(OCR 无损输入) */
88+
/** canvas 物理像素裁剪 → PNG base64(OCR 无损输入;坐标换算走 photoCrop 纯函数) */
8889
const cropAndConfirm = () => {
8990
if (!confirm) return;
9091
const img = new Image();
9192
img.onload = () => {
92-
const sx = Math.round(confirm.x * imageWidth);
93-
const sy = Math.round(confirm.y * imageHeight);
94-
const sw = Math.max(1, Math.round(confirm.w * imageWidth));
95-
const sh = Math.max(1, Math.round(confirm.h * imageHeight));
93+
const r = normToPixels(confirm.x, confirm.y, confirm.w, confirm.h, imageWidth, imageHeight);
9694
const canvas = document.createElement("canvas");
97-
canvas.width = sw;
98-
canvas.height = sh;
95+
canvas.width = r.w;
96+
canvas.height = r.h;
9997
const ctx = canvas.getContext("2d");
10098
if (!ctx) { onCancel(); return; }
101-
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
99+
ctx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
102100
onConfirm(canvas.toDataURL("image/png").split(",")[1] ?? "");
103101
};
104102
img.src = src;

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,12 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
329329
{/* 转写时间轴(字幕为主,语音/融合弱化;段 id 锚点供大纲/搜索跳转) */}
330330
<h3 style={{ fontSize: 13, margin: "12px 0 6px" }}>转写时间轴</h3>
331331
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
332-
{detail.segments.length === 0 && <p style={{ fontSize: 12, color: "#9ca3af" }}>本会话无转写段</p>}
332+
{/* v0.11.7:图文会话空态语义区分(无讲述内容);其余会话维持原文案 */}
333+
{detail.segments.length === 0 && (
334+
<p style={{ fontSize: 12, color: "#9ca3af" }}>
335+
{detail.session.kind === "photo" ? "本会话无讲述内容(图文采集)" : "本会话无转写段"}
336+
</p>
337+
)}
333338
{detail.segments.map((seg) => (
334339
<div key={seg.id} id={`seg-${sessionId}-${seg.id}`} style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
335340
<span style={{ fontSize: 11, color: "#9ca3af", width: 70, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>

‎app/src/components/SessionListPanel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ export default function SessionListPanel({
247247
<span style={{ fontVariantNumeric: "tabular-nums" }}>
248248
#{item.displayNo} · {fmtDate(s.started_at)}
249249
</span>
250+
{/* v0.11.7:会话类型徽标(图文会话标识;视频类不显示零回归) */}
251+
{s.kind === "photo" && <span style={{ fontWeight: 600 }}>📷 图文</span>}
250252
<span>{s.status === "recording" ? "进行中" : fmtDuration(durationMs)}</span>
251253
{s.source_window && (
252254
<span style={{ maxWidth: 130, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>

‎app/src/utils/photoCrop.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* photoCrop 坐标换算测试(v0.11.7 图文截屏)。
3+
*
4+
* @ai-context: DPI 关键路径——归一化坐标 × 物理像素;覆盖边界(0/1)、
5+
* 越界(clamp)、非整数(取整)、零宽(防御 1px)。
6+
*/
7+
import { describe, expect, it } from "vitest";
8+
import { normToPixels } from "./photoCrop";
9+
10+
describe("normToPixels", () => {
11+
it("标准区域按物理像素换算", () => {
12+
// 1920×1080 全屏截图,框选左上 1/4
13+
const r = normToPixels(0, 0, 0.5, 0.5, 1920, 1080);
14+
expect(r).toEqual({ x: 0, y: 0, w: 960, h: 540 });
15+
});
16+
17+
it("DPI 150% 缩放下仍按物理像素(不经 devicePixelRatio)", () => {
18+
// 逻辑窗口 1280×720 → 物理 1920×1080;归一化坐标不变
19+
const r = normToPixels(0.25, 0.25, 0.5, 0.5, 1920, 1080);
20+
expect(r).toEqual({ x: 480, y: 270, w: 960, h: 540 });
21+
});
22+
23+
it("越界输入 clamp 到图像内", () => {
24+
const r = normToPixels(-0.5, 1.5, 2, 2, 100, 100);
25+
expect(r).toEqual({ x: 0, y: 100, w: 100, h: 100 });
26+
});
27+
28+
it("非整数坐标取整(含四舍五入)", () => {
29+
const r = normToPixels(0.333, 0.333, 0.333, 0.333, 1000, 1000);
30+
expect(r).toEqual({ x: 333, y: 333, w: 333, h: 333 });
31+
});
32+
33+
it("零宽/负宽防御为至少 1 像素", () => {
34+
const r = normToPixels(0, 0, 0, 0, 800, 600);
35+
expect(r.w).toBe(1);
36+
expect(r.h).toBe(1);
37+
});
38+
});

‎app/src/utils/photoCrop.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* photoCrop — 图文截屏坐标换算纯函数(v0.11.7)。
3+
*
4+
* @ai-context: 归一化坐标(0-1,框选层相对图像)→ 物理像素(GDI 捕获尺寸)。
5+
* DPI 换算基准:图像物理像素 × 归一化,不经 window.devicePixelRatio
6+
* (显示缩放已由 letterbox 层消化)。clamp 到图像内、宽高取整。
7+
*/
8+
export function normToPixels(
9+
x: number,
10+
y: number,
11+
w: number,
12+
h: number,
13+
imageW: number,
14+
imageH: number,
15+
): { x: number; y: number; w: number; h: number } {
16+
const cx = Math.min(1, Math.max(0, x));
17+
const cy = Math.min(1, Math.max(0, y));
18+
const cw = Math.min(1, Math.max(0, w));
19+
const ch = Math.min(1, Math.max(0, h));
20+
return {
21+
x: Math.round(cx * imageW),
22+
y: Math.round(cy * imageH),
23+
// 越界/零宽防御:至少 1 物理像素(canvas 拒绝 0 尺寸)
24+
w: Math.max(1, Math.round(cw * imageW)),
25+
h: Math.max(1, Math.round(ch * imageH)),
26+
};
27+
}

0 commit comments

Comments
 (0)