fix(work-record): add resizable table columns

This commit is contained in:
WuYongJie 2026-07-24 17:35:49 +08:00
parent 2e43240f51
commit de2c1e1011
2 changed files with 186 additions and 64 deletions

View File

@ -1,6 +1,8 @@
"use client";
import { useState, type ReactNode } from "react";
import { Trash2 } from "lucide-react";
import { SkuColumnResizableFrame } from "@/app/intake/non-integrated/components/SkuTableFields";
import { WorkRecordPrimarySelect } from "@/app/intake/work-record/WorkRecordPrimarySelect";
import {
getWorkRecordRequiredFieldErrors,
@ -9,6 +11,49 @@ import {
} from "@/app/intake/work-record/workRecordValidation";
import type { WorkRecordRow, WorkRecordScoreRule } from "@/lib/work-record";
const WORK_RECORD_COLUMN_DEFAULT_WIDTHS = {
date: 128,
designer: 112,
primary: 144,
secondary: 256,
approvalOrTask: 224,
spu: 128,
quantity: 96,
unitScore: 96,
taskTotal: 112,
fileStoragePath: 180,
} as const;
const WORK_RECORD_ACTION_COLUMN_WIDTH = 48;
const WORK_RECORD_COLUMN_HEADERS: Array<{
key: WorkRecordColumnWidthKey;
label: string;
}> = [
{ key: "date", label: "日期" },
{ key: "designer", label: "设计师" },
{ key: "primary", label: "一级" },
{ key: "secondary", label: "二级" },
{ key: "approvalOrTask", label: "审批编号/具体事宜" },
{ key: "spu", label: "SPU" },
{ key: "quantity", label: "数量" },
{ key: "unitScore", label: "单分值" },
{ key: "taskTotal", label: "单任务合计" },
{ key: "fileStoragePath", label: "文件储存路径" },
];
type WorkRecordColumnWidthKey = keyof typeof WORK_RECORD_COLUMN_DEFAULT_WIDTHS;
function normalizeWorkRecordColumnWidth(
column: WorkRecordColumnWidthKey,
width: number,
) {
return Math.min(
520,
Math.max(WORK_RECORD_COLUMN_DEFAULT_WIDTHS[column], Math.round(width)),
);
}
function formatScore(value: number | null) {
return value === null ? "" : String(value);
}
@ -32,6 +77,29 @@ function inputClassName(invalid: boolean) {
}`;
}
function WorkRecordColumnResizeFrame({
children,
column,
columnWidths,
onColumnResize,
}: {
children: ReactNode;
column: WorkRecordColumnWidthKey;
columnWidths: Record<WorkRecordColumnWidthKey, number>;
onColumnResize: (column: WorkRecordColumnWidthKey, width: number) => void;
}) {
return (
<SkuColumnResizableFrame
className="my-2"
columnWidth={columnWidths[column]}
minWidth={WORK_RECORD_COLUMN_DEFAULT_WIDTHS[column]}
onColumnResize={(width) => onColumnResize(column, width)}
>
{children}
</SkuColumnResizableFrame>
);
}
export function WorkRecordTable({
canEditRecords,
currentUserId,
@ -50,22 +118,49 @@ export function WorkRecordTable({
scoreRules: WorkRecordScoreRule[];
}) {
const primaryOptions = Array.from(new Set(scoreRules.map((rule) => rule.primary)));
const [columnWidths, setColumnWidths] = useState<Record<WorkRecordColumnWidthKey, number>>(
() => ({ ...WORK_RECORD_COLUMN_DEFAULT_WIDTHS }),
);
const tableWidth = WORK_RECORD_COLUMN_HEADERS.reduce(
(total, column) => total + columnWidths[column.key],
WORK_RECORD_ACTION_COLUMN_WIDTH,
);
function handleColumnResize(column: WorkRecordColumnWidthKey, width: number) {
setColumnWidths((current) => ({
...current,
[column]: normalizeWorkRecordColumnWidth(column, width),
}));
}
const columnResizeProps = {
columnWidths,
onColumnResize: handleColumnResize,
};
return (
<div className="overflow-x-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
<table className="w-full min-w-[1380px] border-collapse text-xs">
<table className="table-fixed border-collapse text-xs" style={{ width: tableWidth }}>
<colgroup>
{WORK_RECORD_COLUMN_HEADERS.map((column) => (
<col key={column.key} style={{ width: columnWidths[column.key] }} />
))}
<col style={{ width: WORK_RECORD_ACTION_COLUMN_WIDTH }} />
</colgroup>
<thead className="bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)]">
<tr>
<th className="w-32 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">日期</th>
<th className="w-28 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">设计师</th>
<th className="w-36 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">一级</th>
<th className="w-64 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">二级</th>
<th className="min-w-52 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">审批编号/具体事宜</th>
<th className="w-32 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">SPU</th>
<th className="w-24 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">数量</th>
<th className="w-24 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">单分值</th>
<th className="w-28 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">单任务合计</th>
<th className="min-w-56 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">文件储存路径</th>
{WORK_RECORD_COLUMN_HEADERS.map((column) => (
<th
key={column.key}
className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium"
>
{column.label}
</th>
))}
<th
aria-label="操作"
className="border-b border-[var(--obs-shell-border)] p-0"
/>
</tr>
</thead>
<tbody>
@ -83,58 +178,62 @@ export function WorkRecordTable({
return (
<tr key={record.id} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
<td className="px-2 py-2 align-top"><input aria-label="工作记录日期" readOnly value={record.date} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
<td className="px-2 py-2 align-top"><input aria-label="工作记录设计师" readOnly value={record.designer} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
<td className="px-2 py-2 align-top">
<WorkRecordPrimarySelect
disabled={!canEdit}
options={primaryOptions}
value={record.primary}
onSelect={(primary) => {
const rules = scoreRules.filter((rule) => rule.primary === primary);
const onlyRule = rules.length === 1 ? rules[0] : null;
const unitScore = onlyRule?.unitScore ?? null;
<td className="align-top"><WorkRecordColumnResizeFrame column="date" {...columnResizeProps}><input aria-label="工作记录日期" readOnly value={record.date} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
<td className="align-top"><WorkRecordColumnResizeFrame column="designer" {...columnResizeProps}><input aria-label="工作记录设计师" readOnly value={record.designer} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
<td className="align-top">
<WorkRecordColumnResizeFrame column="primary" {...columnResizeProps}>
<WorkRecordPrimarySelect
disabled={!canEdit}
options={primaryOptions}
value={record.primary}
onSelect={(primary) => {
const rules = scoreRules.filter((rule) => rule.primary === primary);
const onlyRule = rules.length === 1 ? rules[0] : null;
const unitScore = onlyRule?.unitScore ?? null;
const nextRecord = onUpdateRecord(record.id, {
primary,
secondary: onlyRule?.secondary ?? "",
scoreRuleId: onlyRule?.id ?? null,
unitScore,
taskTotal: calculateTaskTotal(record.quantity, unitScore),
});
commitIfReady(nextRecord);
}}
/>
</WorkRecordColumnResizeFrame>
</td>
<td className="align-top">
<WorkRecordColumnResizeFrame column="secondary" {...columnResizeProps}>
<select aria-label="工作记录二级" value={record.scoreRuleId ?? ""} disabled={!canEdit || !record.primary} onChange={(event) => {
const scoreRuleId = event.target.value || null;
const rule = scoreRules.find((item) => item.id === scoreRuleId);
const unitScore = rule?.unitScore ?? null;
const nextRecord = onUpdateRecord(record.id, {
primary,
secondary: onlyRule?.secondary ?? "",
scoreRuleId: onlyRule?.id ?? null,
scoreRuleId,
secondary: rule?.secondary ?? "",
unitScore,
taskTotal: calculateTaskTotal(record.quantity, unitScore),
});
commitIfReady(nextRecord);
}}
/>
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary">
<option value="" />
{secondaryOptions.map((rule) => <option key={rule.id} value={rule.id}>{rule.secondary}</option>)}
</select>
</WorkRecordColumnResizeFrame>
</td>
<td className="px-2 py-2 align-top">
<select aria-label="工作记录二级" value={record.scoreRuleId ?? ""} disabled={!canEdit || !record.primary} onChange={(event) => {
const scoreRuleId = event.target.value || null;
const rule = scoreRules.find((item) => item.id === scoreRuleId);
const unitScore = rule?.unitScore ?? null;
const nextRecord = onUpdateRecord(record.id, {
scoreRuleId,
secondary: rule?.secondary ?? "",
unitScore,
taskTotal: calculateTaskTotal(record.quantity, unitScore),
});
commitIfReady(nextRecord);
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary">
<option value="" />
{secondaryOptions.map((rule) => <option key={rule.id} value={rule.id}>{rule.secondary}</option>)}
</select>
</td>
<td className="px-2 py-2 align-top">
<input aria-label="审批编号或具体事宜" value={record.approvalOrTask} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { approvalOrTask: event.target.value })} onBlur={() => {
<td className="align-top">
<WorkRecordColumnResizeFrame column="approvalOrTask" {...columnResizeProps}><input aria-label="审批编号或具体事宜" value={record.approvalOrTask} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { approvalOrTask: event.target.value })} onBlur={() => {
if (canCommitRecord(record)) onCommitRecord(record.id);
}} className={inputClassName(Boolean(requiredErrors.approvalOrTask))} />
{requiredErrors.approvalOrTask ? <p className="mt-1 text-[11px] text-red-600" role="alert">{requiredErrors.approvalOrTask}</p> : null}
}} className={inputClassName(Boolean(requiredErrors.approvalOrTask))} /></WorkRecordColumnResizeFrame>
{requiredErrors.approvalOrTask ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">{requiredErrors.approvalOrTask}</p> : null}
</td>
<td className="px-2 py-2 align-top">
<input aria-label="工作记录SPU" value={record.spu} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { spu: event.target.value })} onBlur={() => {
<td className="align-top">
<WorkRecordColumnResizeFrame column="spu" {...columnResizeProps}><input aria-label="工作记录SPU" value={record.spu} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { spu: event.target.value })} onBlur={() => {
if (canCommitRecord(record)) onCommitRecord(record.id);
}} className={inputClassName(Boolean(requiredErrors.spu))} />
{requiredErrors.spu ? <p className="mt-1 text-[11px] text-red-600" role="alert">{requiredErrors.spu}</p> : null}
}} className={inputClassName(Boolean(requiredErrors.spu))} /></WorkRecordColumnResizeFrame>
{requiredErrors.spu ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">{requiredErrors.spu}</p> : null}
</td>
<td className="px-2 py-2 align-top"><input aria-label="工作记录数量" type="number" min="1" step="1" value={record.quantity} disabled={!canEdit} onChange={(event) => {
<td className="align-top"><WorkRecordColumnResizeFrame column="quantity" {...columnResizeProps}><input aria-label="工作记录数量" type="number" min="1" step="1" value={record.quantity} disabled={!canEdit} onChange={(event) => {
const quantity = event.target.value;
onUpdateRecord(record.id, {
quantity,
@ -142,10 +241,10 @@ export function WorkRecordTable({
});
}} onBlur={() => {
if (canCommitRecord(record)) onCommitRecord(record.id);
}} className={inputClassName(hasInvalidQuantity)} />
{hasInvalidQuantity ? <p className="mt-1 text-[11px] text-red-600" role="alert">数量必须是正整数</p> : null}
}} className={inputClassName(hasInvalidQuantity)} /></WorkRecordColumnResizeFrame>
{hasInvalidQuantity ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">数量必须是正整数</p> : null}
</td>
<td className="px-2 py-2 align-top"><input aria-label="工作记录单分值" type="number" min="0" step="0.01" value={formatScore(record.unitScore)} disabled={!canEdit || !isOther} onChange={(event) => {
<td className="align-top"><WorkRecordColumnResizeFrame column="unitScore" {...columnResizeProps}><input aria-label="工作记录单分值" type="number" min="0" step="0.01" value={formatScore(record.unitScore)} disabled={!canEdit || !isOther} onChange={(event) => {
const unitScore = event.target.value === "" ? null : Number(event.target.value);
onUpdateRecord(record.id, {
unitScore,
@ -153,15 +252,17 @@ export function WorkRecordTable({
});
}} onBlur={() => {
if (canCommitRecord(record)) onCommitRecord(record.id);
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" /></td>
<td className="px-2 py-2 align-top"><input aria-label="工作记录单任务合计" readOnly value={formatScore(record.taskTotal)} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" /></WorkRecordColumnResizeFrame></td>
<td className="align-top"><WorkRecordColumnResizeFrame column="taskTotal" {...columnResizeProps}><input aria-label="工作记录单任务合计" readOnly value={formatScore(record.taskTotal)} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
<td className="align-top">
<WorkRecordColumnResizeFrame column="fileStoragePath" {...columnResizeProps}>
<input aria-label="文件储存路径" value={record.fileStoragePath} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { fileStoragePath: event.target.value })} onBlur={() => {
if (canCommitRecord(record)) onCommitRecord(record.id);
}} className="h-8 min-w-0 flex-1 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" />
{canEdit ? <button type="button" aria-label="删除工作记录" title="删除工作记录" onClick={() => onRemoveRecord(record.id)} className="inline-flex size-8 shrink-0 items-center justify-center rounded-[6px] text-apple-label-secondary transition hover:bg-red-50 hover:text-red-600"><Trash2 className="size-4" /></button> : null}
</div>
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" />
</WorkRecordColumnResizeFrame>
</td>
<td className="align-top">
{canEdit ? <button type="button" aria-label="删除工作记录" title="删除工作记录" onClick={() => onRemoveRecord(record.id)} className="mt-2 inline-flex size-8 items-center justify-center rounded-[6px] text-apple-label-secondary transition hover:bg-red-50 hover:text-red-600"><Trash2 className="size-4" /></button> : null}
</td>
</tr>
);

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorkRecordWorkspace } from "@/app/intake/work-record/WorkRecordWorkspace";
import {
@ -209,4 +209,25 @@ describe("WorkRecordWorkspace", () => {
expect(quantityInput).toHaveAttribute("step", "1");
expect(await screen.findByText("数量必须是正整数")).toBeInTheDocument();
});
it("keeps the file storage path column compact and lets users widen it", async () => {
const { container } = renderWorkspace();
await screen.findByDisplayValue("SPU-001");
const columns = container.querySelectorAll("colgroup col");
expect(columns).toHaveLength(11);
expect(columns[9]).toHaveStyle({ width: "180px" });
const resizeHandles = screen.getAllByRole("separator");
const pointerDown = new Event("pointerdown", { bubbles: true });
Object.defineProperty(pointerDown, "clientX", { value: 100 });
fireEvent(resizeHandles[9], pointerDown);
const pointerMove = new Event("pointermove");
Object.defineProperty(pointerMove, "clientX", { value: 220 });
fireEvent(window, pointerMove);
fireEvent.pointerUp(window);
expect(columns[9]).toHaveStyle({ width: "300px" });
});
});