8 Commits

Author SHA1 Message Date
0bdc8a067b fix: satisfy attendance workflow lint
Some checks failed
CI / check (pull_request) Failing after 3m33s
2026-07-23 11:48:50 +08:00
490a337446 Merge remote-tracking branch 'origin/main' into fix/pr41-ci 2026-07-23 11:47:30 +08:00
302dbe0621 Merge pull request 'fix: 避免 PR #40 触屏双击时缩放页面' (#42) from fix/pr40-touch-action into main 2026-07-23 03:32:57 +00:00
5bd63846d1 fix: prevent touch double-tap zoom
All checks were successful
CI / check (pull_request) Successful in 3m46s
2026-07-23 11:27:41 +08:00
301064dbf6 Merge pull request '修复手机端双击编辑单元格' (#40) from xiongyuxing/gongxue-base:main into main 2026-07-23 03:26:42 +00:00
fa7ed8e128 feat:移除表格内部的滚动
All checks were successful
CI / check (pull_request) Successful in 1m51s
2026-07-23 11:22:31 +08:00
a267238e7c merge upstream
All checks were successful
CI / check (pull_request) Successful in 3m5s
2026-07-23 01:44:00 +00:00
d93f6cb0b9 修复手机端双击编辑单元格 2026-07-23 09:42:33 +08:00
5 changed files with 126 additions and 11 deletions

View File

@@ -13,6 +13,21 @@ let root: ReturnType<typeof createRoot> | null = null;
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const dispatchPointer = (
target: Element,
type: 'pointerdown' | 'pointerup',
init: { pointerType: 'touch' | 'mouse'; pointerId?: number; clientX?: number; clientY?: number },
) => {
const event = new Event(type, { bubbles: true });
Object.defineProperties(event, {
pointerType: { value: init.pointerType },
pointerId: { value: init.pointerId ?? 1 },
clientX: { value: init.clientX ?? 0 },
clientY: { value: init.clientY ?? 0 },
});
target.dispatchEvent(event);
};
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
@@ -51,6 +66,70 @@ describe('editable cell value mapping', () => {
});
describe('editable cell interactions', () => {
const renderTextCell = async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(EditableCell, { value: '张三', onSave }, '张三'),
);
});
return container.querySelector('.editable-cell') as HTMLElement;
};
it('keeps a single touch tap read-only', async () => {
const cell = await renderTextCell();
await act(async () => {
dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 });
dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 12, clientY: 11 });
await flush();
});
expect(container?.querySelector('.editable-cell--editing')).toBeNull();
});
it('enters edit mode after two nearby touch taps', async () => {
const cell = await renderTextCell();
await act(async () => {
dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 });
dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 11, clientY: 10 });
dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 12, clientY: 11 });
dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 12, clientY: 11 });
await flush();
});
expect(container?.querySelector('.editable-cell--editing')).toBeTruthy();
});
it('keeps mouse single-click behavior read-only', async () => {
const cell = await renderTextCell();
await act(async () => {
dispatchPointer(cell, 'pointerdown', { pointerType: 'mouse' });
dispatchPointer(cell, 'pointerup', { pointerType: 'mouse' });
await flush();
});
expect(container?.querySelector('.editable-cell--editing')).toBeNull();
});
it('does not edit when a touch gesture scrolls the table', async () => {
const cell = await renderTextCell();
await act(async () => {
dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 });
dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 30, clientY: 10 });
await flush();
});
expect(container?.querySelector('.editable-cell--editing')).toBeNull();
});
it('saves a single-select value immediately when an option is clicked', async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement('div');

View File

@@ -94,6 +94,8 @@ const EditableCell = <Value,>({
const { hasPermission } = usePermission();
const idRef = useRef(crypto.randomUUID());
const rootRef = useRef<HTMLDivElement>(null);
const touchStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const lastTouchTapRef = useRef<{ time: number; x: number; y: number } | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<unknown>(() =>
@@ -200,6 +202,39 @@ const EditableCell = <Value,>({
setEditing(true);
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch' || editing) return;
touchStartRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
};
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const touchStart = touchStartRef.current;
touchStartRef.current = null;
if (!touchStart || event.pointerType !== 'touch' || event.pointerId !== touchStart.pointerId) {
return;
}
const moved = Math.hypot(event.clientX - touchStart.x, event.clientY - touchStart.y);
if (moved > 8) {
lastTouchTapRef.current = null;
return;
}
const now = Date.now();
const lastTap = lastTouchTapRef.current;
const isDoubleTap =
!!lastTap &&
now - lastTap.time <= 450 &&
Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= 24;
lastTouchTapRef.current = isDoubleTap
? null
: { time: now, x: event.clientX, y: event.clientY };
if (isDoubleTap) void beginEdit();
};
const onKeyDown = async (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -264,11 +299,17 @@ const EditableCell = <Value,>({
ref={rootRef}
className={`editable-cell${enabled ? ' editable-cell--enabled' : ''}${editing ? ' editable-cell--editing' : ''}`}
onDoubleClick={() => void beginEdit()}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => {
touchStartRef.current = null;
lastTouchTapRef.current = null;
}}
>
{editing ? (
<Spin spinning={saving}>{control}</Spin>
) : (
<Tooltip title={enabled ? '双击编辑' : undefined}>{children}</Tooltip>
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>{children}</Tooltip>
)}
</div>
);

View File

@@ -7,6 +7,7 @@
.editable-cell--enabled {
cursor: cell;
touch-action: manipulation;
padding: 4px 6px;
margin: -4px -6px;
border: 1px solid transparent;

View File

@@ -188,8 +188,7 @@ canvas {
max-width: 100%;
}
.ant-table-wrapper .ant-table-container {
overflow-x: auto;
.ant-table-wrapper .ant-table-content {
overscroll-behavior-inline: contain;
-webkit-overflow-scrolling: touch;
}

View File

@@ -103,11 +103,6 @@ describe('attendance workflow integration', () => {
});
it('imports students, builds a teacher class schedule, refreshes punches, and scopes reads', async () => {
let classId: number;
let scheduleId: number;
let studentA: Student;
let studentB: Student;
const roleRepo = app.get<Repository<Role>>(getRepositoryToken(Role));
const userRepo = app.get<Repository<User>>(getRepositoryToken(User));
const studentRepo = app.get<Repository<Student>>(getRepositoryToken(Student));
@@ -168,7 +163,7 @@ describe('attendance workflow integration', () => {
.expect(201);
expect(importResult.body).toMatchObject({ imported: 2, skipped: 0 });
[studentA, studentB] = await Promise.all([
const [studentA, studentB] = await Promise.all([
studentRepo.findOneByOrFail({ phone: '13800000001' }),
studentRepo.findOneByOrFail({ phone: '13800000002' }),
]);
@@ -189,7 +184,7 @@ describe('attendance workflow integration', () => {
endDate: LESSON_DATE,
})
.expect(201);
classId = classResult.body.id;
const classId = classResult.body.id;
await request(app.getHttpServer())
.post(`/api/classes/${classId}/students`)
@@ -227,7 +222,7 @@ describe('attendance workflow integration', () => {
scheduleType: 'INTERNAL',
})
.expect(201);
scheduleId = scheduleResult.body.id;
const scheduleId = scheduleResult.body.id;
const initialPull = await request(app.getHttpServer())
.post(`/api/attendance-lessons/schedules/${scheduleId}/pull`)