feat(work-record): manage score rules

This commit is contained in:
WuYongJie 2026-07-29 15:59:41 +08:00
parent 60f252571a
commit 679e726a14
10 changed files with 841 additions and 126 deletions

View File

@ -52,12 +52,20 @@ export async function PATCH(request: NextRequest) {
ctx.requestId,
);
}
const scoreRule =
body && typeof body === "object" && "scoreRule" in body
? (body as { scoreRule?: unknown }).scoreRule
: body;
return jsonSuccess(
{
scoreRule: await workRecordService.updateScoreRule(
ctx.user,
id,
(body as { unitScore?: unknown }).unitScore,
(scoreRule ?? {}) as {
primary?: unknown;
secondary?: unknown;
unitScore?: unknown;
},
),
},
200,
@ -65,3 +73,40 @@ export async function PATCH(request: NextRequest) {
);
});
}
export async function DELETE(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null);
const id = body && typeof body === "object"
? String((body as { id?: unknown }).id ?? "").trim()
: "";
if (!id) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"缺少分值规则标识",
400,
ctx.requestId,
);
}
return jsonSuccess(
await workRecordService.deleteScoreRule(ctx.user, id),
200,
ctx.requestId,
);
});
}
export async function PUT(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null);
const orderedIds =
body && typeof body === "object"
? (body as { orderedIds?: unknown }).orderedIds
: undefined;
return jsonSuccess(
{ scoreRules: await workRecordService.reorderScoreRules(ctx.user, orderedIds) },
200,
ctx.requestId,
);
});
}

View File

@ -0,0 +1,73 @@
"use client";
import type { FormEvent } from "react";
import { Check, X } from "lucide-react";
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
type WorkRecordScoreRuleCreateFormProps = {
draft: WorkRecordScoreRuleDraft;
isSaving: boolean;
onChange: (draft: WorkRecordScoreRuleDraft) => void;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
};
export function WorkRecordScoreRuleCreateForm({
draft,
isSaving,
onChange,
onCancel,
onSubmit,
}: WorkRecordScoreRuleCreateFormProps) {
return (
<form
onSubmit={onSubmit}
className="grid shrink-0 min-w-0 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7rem_auto] items-center gap-2 border-b border-[var(--obs-shell-border)] bg-apple-bg-secondary px-4 py-2"
>
<input
aria-label="新增一级"
required
value={draft.primary}
onChange={(event) => onChange({ ...draft, primary: event.target.value })}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<input
aria-label="新增二级"
required
value={draft.secondary}
onChange={(event) => onChange({ ...draft, secondary: event.target.value })}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<input
aria-label="新增单分值"
type="number"
min="0"
step="0.01"
value={draft.unitScore}
onChange={(event) => onChange({ ...draft, unitScore: event.target.value })}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<div className="flex items-center gap-1">
<button
type="submit"
title="保存新增规则"
aria-label="保存新增规则"
disabled={isSaving}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<Check className="size-4" />
</button>
<button
type="button"
title="取消新增规则"
aria-label="取消新增规则"
disabled={isSaving}
onClick={onCancel}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
>
<X className="size-4" />
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import { useState, type CSSProperties, type KeyboardEvent } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Check, GripVertical, Pencil, Trash2, X } from "lucide-react";
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
import type { WorkRecordScoreRule } from "@/lib/work-record";
function toDraft(rule: WorkRecordScoreRule): WorkRecordScoreRuleDraft {
return {
primary: rule.primary,
secondary: rule.secondary,
unitScore: rule.unitScore === null ? "" : String(rule.unitScore),
};
}
type WorkRecordScoreRuleRowProps = {
rule: WorkRecordScoreRule;
canManageScoreRules: boolean;
isSaving: boolean;
onSave: (
rule: WorkRecordScoreRule,
draft: WorkRecordScoreRuleDraft,
) => Promise<boolean>;
onDelete: (rule: WorkRecordScoreRule) => void;
};
export function WorkRecordScoreRuleRow({
rule,
canManageScoreRules,
isSaving,
onSave,
onDelete,
}: WorkRecordScoreRuleRowProps) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<WorkRecordScoreRuleDraft>(() => toDraft(rule));
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: rule.id,
disabled: !canManageScoreRules || isSaving,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.7 : 1,
position: "relative",
zIndex: isDragging ? 1 : undefined,
};
const cancelEditing = () => {
setDraft(toDraft(rule));
setIsEditing(false);
};
const saveEditing = async () => {
if (!canManageScoreRules || isSaving) return;
if (await onSave(rule, draft)) setIsEditing(false);
};
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
event.preventDefault();
void saveEditing();
};
return (
<tr
ref={setNodeRef}
style={style}
className="border-b border-[var(--obs-shell-border)] bg-white last:border-b-0"
>
<td className="relative px-3 py-2 text-center text-apple-label">
{canManageScoreRules ? (
<button
type="button"
{...attributes}
{...listeners}
title="拖动排序"
aria-label={`拖动规则 ${rule.primary}-${rule.secondary}`}
disabled={isSaving}
className="absolute left-2 top-1/2 z-10 inline-flex size-6 -translate-y-1/2 cursor-grab items-center justify-center rounded-[4px] text-apple-label-tertiary transition hover:bg-apple-bg-secondary hover:text-apple-label active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical className="size-4" />
</button>
) : null}
{isEditing ? (
<input
aria-label="编辑一级"
value={draft.primary}
onChange={(event) =>
setDraft((current) => ({ ...current, primary: event.target.value }))
}
onKeyDown={handleEditorKeyDown}
disabled={isSaving}
className="ml-9 h-8 w-[calc(100%-2.25rem)] min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
/>
) : (
<span title={rule.primary} className="ml-9 block w-[calc(100%-2.25rem)] truncate whitespace-nowrap text-center">{rule.primary}</span>
)}
</td>
<td className="px-3 py-2 text-center text-apple-label">
{isEditing ? (
<input
aria-label="编辑二级"
value={draft.secondary}
onChange={(event) =>
setDraft((current) => ({ ...current, secondary: event.target.value }))
}
onKeyDown={handleEditorKeyDown}
disabled={isSaving}
className="h-8 w-full min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
/>
) : (
<span title={rule.secondary} className="block truncate whitespace-nowrap text-center">{rule.secondary}</span>
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
aria-label="编辑单分值"
type="number"
min="0"
step="0.01"
value={draft.unitScore}
onChange={(event) =>
setDraft((current) => ({ ...current, unitScore: event.target.value }))
}
onKeyDown={handleEditorKeyDown}
disabled={isSaving}
className="h-8 w-full min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
/>
) : (
<input
aria-label={`${rule.primary}-${rule.secondary}-单分值`}
type="number"
value={rule.unitScore ?? ""}
readOnly
className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-center text-xs text-apple-label-secondary outline-none"
/>
)}
</td>
{canManageScoreRules ? (
<td className="sticky right-0 z-10 w-28 bg-white px-3 py-2">
<div className="flex items-center justify-center gap-1">
{isEditing ? (
<>
<button
type="button"
title="保存修改"
aria-label={`保存规则 ${rule.primary}-${rule.secondary}`}
disabled={isSaving}
onClick={() => void saveEditing()}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<Check className="size-4" />
</button>
<button
type="button"
title="取消修改"
aria-label={`取消修改 ${rule.primary}-${rule.secondary}`}
disabled={isSaving}
onClick={cancelEditing}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
>
<X className="size-4" />
</button>
</>
) : (
<>
<button
type="button"
title="修改规则"
aria-label={`编辑规则 ${rule.primary}-${rule.secondary}`}
disabled={isSaving}
onClick={() => setIsEditing(true)}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
>
<Pencil className="size-4" />
</button>
<button
type="button"
title="删除规则"
aria-label={`删除规则 ${rule.primary}-${rule.secondary}`}
disabled={isSaving}
onClick={() => onDelete(rule)}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-red-200 bg-white text-red-600 transition hover:border-red-300 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 className="size-4" />
</button>
</>
)}
</div>
</td>
) : null}
</tr>
);
}

View File

@ -3,20 +3,26 @@ import userEvent from "@testing-library/user-event";
import { WorkRecordScoreWorkspace } from "@/app/intake/work-record/WorkRecordScoreWorkspace";
import {
createWorkRecordScoreRule,
deleteWorkRecordScoreRule,
fetchWorkRecordScoreRules,
reorderWorkRecordScoreRules,
updateWorkRecordScoreRule,
} from "@/app/intake/work-record/workRecordApi";
import type { WorkRecordScoreRule } from "@/lib/work-record";
jest.mock("@/app/intake/work-record/workRecordApi", () => ({
createWorkRecordScoreRule: jest.fn(),
deleteWorkRecordScoreRule: jest.fn(),
fetchWorkRecordScoreRules: jest.fn(),
reorderWorkRecordScoreRules: jest.fn(),
updateWorkRecordScoreRule: jest.fn(),
}));
const mockCreateWorkRecordScoreRule = jest.mocked(createWorkRecordScoreRule);
const mockFetchWorkRecordScoreRules = jest.mocked(fetchWorkRecordScoreRules);
const mockUpdateWorkRecordScoreRule = jest.mocked(updateWorkRecordScoreRule);
const mockDeleteWorkRecordScoreRule = jest.mocked(deleteWorkRecordScoreRule);
const mockReorderWorkRecordScoreRules = jest.mocked(reorderWorkRecordScoreRules);
const scoreRule: WorkRecordScoreRule = {
id: "score-1",
@ -32,6 +38,8 @@ describe("WorkRecordScoreWorkspace", () => {
mockFetchWorkRecordScoreRules.mockResolvedValue({ scoreRules: [scoreRule] });
mockCreateWorkRecordScoreRule.mockResolvedValue({ scoreRule });
mockUpdateWorkRecordScoreRule.mockResolvedValue({ scoreRule });
mockDeleteWorkRecordScoreRule.mockResolvedValue({ id: scoreRule.id });
mockReorderWorkRecordScoreRules.mockResolvedValue({ scoreRules: [scoreRule] });
});
it("keeps score inputs read-only for a designer", async () => {
@ -40,20 +48,63 @@ describe("WorkRecordScoreWorkspace", () => {
expect(await screen.findByRole("spinbutton")).toHaveAttribute("readonly");
});
it("persists a supervisor score change on blur", async () => {
it("lets a supervisor edit the primary, secondary, and unit score", async () => {
const user = userEvent.setup();
mockUpdateWorkRecordScoreRule.mockResolvedValueOnce({
scoreRule: { ...scoreRule, unitScore: 16 },
scoreRule: {
...scoreRule,
primary: "Updated primary",
secondary: "Updated secondary",
unitScore: 16,
},
});
render(<WorkRecordScoreWorkspace canManageScoreRules />);
const input = await screen.findByRole("spinbutton");
await user.clear(input);
await user.type(input, "16");
await user.tab();
await screen.findByRole("spinbutton");
await user.click(screen.getByRole("button", { name: "编辑规则 Primary-Secondary" }));
await user.clear(screen.getByLabelText("编辑一级"));
await user.type(screen.getByLabelText("编辑一级"), "Updated primary");
await user.clear(screen.getByLabelText("编辑二级"));
await user.type(screen.getByLabelText("编辑二级"), "Updated secondary");
await user.clear(screen.getByLabelText("编辑单分值"));
await user.type(screen.getByLabelText("编辑单分值"), "16");
await user.click(screen.getByRole("button", { name: "保存规则 Primary-Secondary" }));
await waitFor(() =>
expect(mockUpdateWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id, 16),
expect(mockUpdateWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id, {
primary: "Updated primary",
secondary: "Updated secondary",
unitScore: 16,
}),
);
});
it("shows drag, edit, and delete controls only to a supervisor", async () => {
render(<WorkRecordScoreWorkspace canManageScoreRules />);
expect(
await screen.findByRole("button", { name: "拖动规则 Primary-Secondary" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "删除规则 Primary-Secondary" }),
).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "一级" })).toHaveClass("sticky");
expect(screen.getByRole("columnheader", { name: "操作" })).toHaveClass("sticky", "right-0");
expect(screen.getByText(scoreRule.primary)).toHaveClass("truncate", "whitespace-nowrap");
expect(screen.getByText(scoreRule.secondary)).toHaveClass("truncate", "whitespace-nowrap");
});
it("deactivates a selected score rule after confirmation", async () => {
const user = userEvent.setup();
jest.spyOn(window, "confirm").mockReturnValue(true);
render(<WorkRecordScoreWorkspace canManageScoreRules />);
await user.click(
await screen.findByRole("button", { name: "删除规则 Primary-Secondary" }),
);
await waitFor(() =>
expect(mockDeleteWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id),
);
});

View File

@ -1,31 +1,37 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { Check, CloudUpload, Plus, TriangleAlert, X } from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Check, CloudUpload, Plus, TriangleAlert } from "lucide-react";
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
import {
createWorkRecordScoreRule,
deleteWorkRecordScoreRule,
fetchWorkRecordScoreRules,
reorderWorkRecordScoreRules,
updateWorkRecordScoreRule,
} from "@/app/intake/work-record/workRecordApi";
import {
WorkRecordScoreRuleRow,
} from "@/app/intake/work-record/WorkRecordScoreRuleRow";
import { WorkRecordScoreRuleCreateForm } from "@/app/intake/work-record/WorkRecordScoreRuleCreateForm";
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
import type { WorkRecordScoreRule } from "@/lib/work-record";
type ScoreRuleDraft = {
primary: string;
secondary: string;
unitScore: string;
};
const EMPTY_SCORE_RULE_DRAFT: ScoreRuleDraft = {
const EMPTY_SCORE_RULE_DRAFT: WorkRecordScoreRuleDraft = {
primary: "",
secondary: "",
unitScore: "",
};
function formatScore(value: number | null) {
return value === null ? "" : String(value);
}
export function WorkRecordScoreWorkspace({
canManageScoreRules,
}: {
@ -35,10 +41,13 @@ export function WorkRecordScoreWorkspace({
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newRule, setNewRule] = useState<ScoreRuleDraft>(
const [newRule, setNewRule] = useState<WorkRecordScoreRuleDraft>(
EMPTY_SCORE_RULE_DRAFT,
);
const [errorMessage, setErrorMessage] = useState("");
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
useEffect(() => {
let active = true;
@ -47,7 +56,11 @@ export function WorkRecordScoreWorkspace({
if (active) setScoreRules(result.scoreRules);
})
.catch((error) => {
if (active) setErrorMessage(error instanceof Error ? error.message : "加载产品工作分数失败");
if (active) {
setErrorMessage(
error instanceof Error ? error.message : "加载产品工作分数失败",
);
}
})
.finally(() => {
if (active) setIsLoading(false);
@ -57,15 +70,29 @@ export function WorkRecordScoreWorkspace({
};
}, []);
const saveRule = async (rule: WorkRecordScoreRule) => {
if (!canManageScoreRules) return;
const saveEditedRule = async (
rule: WorkRecordScoreRule,
draft: WorkRecordScoreRuleDraft,
) => {
if (!canManageScoreRules || isSaving) return false;
setIsSaving(true);
setErrorMessage("");
try {
const result = await updateWorkRecordScoreRule(rule.id, rule.unitScore);
setScoreRules((current) => current.map((item) => item.id === rule.id ? result.scoreRule : item));
const result = await updateWorkRecordScoreRule(rule.id, {
primary: draft.primary.trim(),
secondary: draft.secondary.trim(),
unitScore: draft.unitScore === "" ? null : Number(draft.unitScore),
});
setScoreRules((current) =>
current.map((item) => (item.id === rule.id ? result.scoreRule : item)),
);
return true;
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "保存产品工作分数失败");
setErrorMessage(
error instanceof Error ? error.message : "保存产品工作分数失败",
);
return false;
} finally {
setIsSaving(false);
}
@ -87,16 +114,68 @@ export function WorkRecordScoreWorkspace({
setNewRule(EMPTY_SCORE_RULE_DRAFT);
setIsCreating(false);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "新增产品工作分数失败");
setErrorMessage(
error instanceof Error ? error.message : "新增产品工作分数失败",
);
} finally {
setIsSaving(false);
}
};
const deleteRule = async (rule: WorkRecordScoreRule) => {
if (
!canManageScoreRules ||
isSaving ||
!window.confirm(`删除“${rule.primary} / ${rule.secondary}”规则?已有工作记录不会删除。`)
) {
return;
}
setIsSaving(true);
setErrorMessage("");
try {
await deleteWorkRecordScoreRule(rule.id);
setScoreRules((current) => current.filter((item) => item.id !== rule.id));
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "删除产品工作分数失败",
);
} finally {
setIsSaving(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
if (!canManageScoreRules || isSaving) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const previousRules = scoreRules;
const oldIndex = previousRules.findIndex((rule) => rule.id === active.id);
const newIndex = previousRules.findIndex((rule) => rule.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const reorderedRules = arrayMove(previousRules, oldIndex, newIndex).map(
(rule, index) => ({ ...rule, sortOrder: index + 1 }),
);
setScoreRules(reorderedRules);
setIsSaving(true);
setErrorMessage("");
void reorderWorkRecordScoreRules(reorderedRules.map((rule) => rule.id))
.then((result) => setScoreRules(result.scoreRules))
.catch((error) => {
setScoreRules(previousRules);
setErrorMessage(
error instanceof Error ? error.message : "保存分值规则排序失败",
);
})
.finally(() => setIsSaving(false));
};
return (
<BrandFilingWorkbenchSurface>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-4 py-3">
<div className="flex h-[calc(100dvh-11rem)] min-h-[420px] min-w-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--obs-shell-border)] px-4 py-3">
<h2 className="text-sm font-semibold text-[var(--obs-shell-title)]">产品工作分数</h2>
<div className="flex items-center gap-2">
{canManageScoreRules ? (
@ -115,96 +194,88 @@ export function WorkRecordScoreWorkspace({
新增规则
</button>
) : null}
{isSaving ? <span title="保存中"><CloudUpload className="size-4 animate-pulse text-apple-blue" /></span> : <span title="已保存"><Check className="size-4 text-emerald-600" /></span>}
{isSaving ? (
<span title="保存中">
<CloudUpload className="size-4 animate-pulse text-apple-blue" />
</span>
) : (
<span title="已保存">
<Check className="size-4 text-emerald-600" />
</span>
)}
</div>
</div>
{errorMessage ? <div className="mx-4 mt-3 flex items-center gap-2 rounded-[6px] border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert"><TriangleAlert className="size-4 shrink-0" />{errorMessage}</div> : null}
<div className="min-h-0 flex-1 overflow-auto p-4">
{isLoading ? <div className="flex h-24 items-center justify-center text-xs text-apple-label-secondary">加载中</div> : (
<div className="overflow-x-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
{isCreating && canManageScoreRules ? (
<form
onSubmit={createRule}
className="grid min-w-[720px] grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7rem_auto] items-center gap-2 border-b border-[var(--obs-shell-border)] bg-apple-bg-secondary px-3 py-2"
>
<input
aria-label="新增一级"
required
value={newRule.primary}
onChange={(event) => setNewRule((current) => ({
...current,
primary: event.target.value,
}))}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<input
aria-label="新增二级"
required
value={newRule.secondary}
onChange={(event) => setNewRule((current) => ({
...current,
secondary: event.target.value,
}))}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<input
aria-label="新增单分值"
type="number"
min="0"
step="0.01"
value={newRule.unitScore}
onChange={(event) => setNewRule((current) => ({
...current,
unitScore: event.target.value,
}))}
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
/>
<div className="flex items-center gap-1">
<button
type="submit"
title="保存新增规则"
aria-label="保存新增规则"
disabled={isSaving}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<Check className="size-4" />
</button>
<button
type="button"
title="取消新增规则"
aria-label="取消新增规则"
disabled={isSaving}
onClick={() => {
setNewRule(EMPTY_SCORE_RULE_DRAFT);
setIsCreating(false);
setErrorMessage("");
}}
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
>
<X className="size-4" />
</button>
</div>
</form>
) : null}
<table className="w-full min-w-[720px] border-collapse text-xs">
<thead className="bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)]"><tr><th className="w-56 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">一级</th><th className="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></tr></thead>
<tbody>
{scoreRules.map((rule) => (
<tr key={rule.id} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
<td className="px-3 py-2 text-apple-label">{rule.primary}</td>
<td className="px-3 py-2 text-apple-label">{rule.secondary}</td>
<td className="px-3 py-2"><input aria-label={`${rule.primary}-${rule.secondary}-单分值`} type="number" min="0" step="0.01" value={formatScore(rule.unitScore)} readOnly={!canManageScoreRules} onChange={(event) => {
const nextValue = event.target.value;
setScoreRules((current) => current.map((item) => item.id === rule.id ? { ...item, unitScore: nextValue === "" ? null : Number(nextValue) } : item));
}} onBlur={(event) => {
const unitScore = event.target.value === "" ? null : Number(event.target.value);
void saveRule({ ...rule, unitScore });
}} 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 read-only:cursor-default read-only:bg-apple-bg-secondary read-only:text-apple-label-secondary" /></td>
{isCreating && canManageScoreRules ? (
<WorkRecordScoreRuleCreateForm
draft={newRule}
isSaving={isSaving}
onChange={setNewRule}
onSubmit={createRule}
onCancel={() => {
setNewRule(EMPTY_SCORE_RULE_DRAFT);
setIsCreating(false);
setErrorMessage("");
}}
/>
) : null}
{errorMessage ? (
<div className="mx-4 mt-3 flex shrink-0 items-center gap-2 rounded-[6px] border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert">
<TriangleAlert className="size-4 shrink-0" />
{errorMessage}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4">
{isLoading ? (
<div className="flex h-24 items-center justify-center text-xs text-apple-label-secondary">加载中</div>
) : (
<DndContext
sensors={canManageScoreRules ? sensors : undefined}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="flex h-full min-h-0 w-full flex-col rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full min-w-[900px] table-fixed border-collapse text-xs">
<colgroup>
<col className="w-72" />
<col />
<col className="w-28" />
{canManageScoreRules ? <col className="w-28" /> : null}
</colgroup>
<thead className="text-[var(--obs-shell-title)]">
<tr>
<th className="sticky top-0 z-20 w-72 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] py-2 pl-12 pr-3 text-center font-medium">一级</th>
<th className="sticky top-0 z-20 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">二级</th>
<th className="sticky top-0 z-20 w-28 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">单分值</th>
{canManageScoreRules ? (
<th className="sticky right-0 top-0 z-30 w-28 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">操作</th>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</thead>
<SortableContext
items={scoreRules.map((rule) => rule.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{scoreRules.map((rule) => (
<WorkRecordScoreRuleRow
key={rule.id}
rule={rule}
canManageScoreRules={canManageScoreRules}
isSaving={isSaving}
onSave={saveEditedRule}
onDelete={(target) => void deleteRule(target)}
/>
))}
</tbody>
</SortableContext>
</table>
</div>
</div>
</DndContext>
)}
</div>
</div>

View File

@ -89,15 +89,40 @@ export async function createWorkRecordScoreRule(rule: {
return readApiData<{ scoreRule: WorkRecordScoreRule }>(response);
}
export async function updateWorkRecordScoreRule(id: string, unitScore: number | null) {
export async function updateWorkRecordScoreRule(
id: string,
scoreRule: {
primary: string;
secondary: string;
unitScore: number | null;
},
) {
const response = await fetch("/api/intake/work-record/scores", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, unitScore }),
body: JSON.stringify({ id, scoreRule }),
});
return readApiData<{ scoreRule: WorkRecordScoreRule }>(response);
}
export async function deleteWorkRecordScoreRule(id: string) {
const response = await fetch("/api/intake/work-record/scores", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
return readApiData<{ id: string }>(response);
}
export async function reorderWorkRecordScoreRules(orderedIds: string[]) {
const response = await fetch("/api/intake/work-record/scores", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds }),
});
return readApiData<{ scoreRules: WorkRecordScoreRule[] }>(response);
}
export async function fetchCurrentWorkRecordScoreSummary() {
const response = await fetch("/api/intake/work-record/summary", { cache: "no-store" });
return readApiData<{ summary: WorkRecordScoreSummary[] }>(response);

View File

@ -0,0 +1,5 @@
export type WorkRecordScoreRuleDraft = {
primary: string;
secondary: string;
unitScore: string;
};

View File

@ -109,13 +109,34 @@ class WorkRecordRepository {
return prisma.workRecordScoreRule.create({ data });
}
async updateScoreRule(id: string, unitScore: Prisma.Decimal | null) {
async updateScoreRule(
id: string,
data: Prisma.WorkRecordScoreRuleUpdateInput,
) {
return prisma.workRecordScoreRule.update({
where: { id },
data: { unitScore },
data,
});
}
async deactivateScoreRule(id: string) {
return prisma.workRecordScoreRule.update({
where: { id },
data: { isActive: false },
});
}
async reorderScoreRules(ids: string[]) {
await prisma.$transaction(
ids.map((id, index) =>
prisma.workRecordScoreRule.update({
where: { id },
data: { sortOrder: index + 1 },
}),
),
);
}
async summarizeByDesigner(periodStart: Date, periodEnd: Date) {
return prisma.workRecord.groupBy({
by: ["designerId", "designerName"],

View File

@ -5,10 +5,13 @@ jest.mock("@/server/repositories/work-record.repository", () => ({
createScoreRule: jest.fn(),
createManyRecords: jest.fn(),
createRecord: jest.fn(),
deactivateScoreRule: jest.fn(),
findActiveScoreRuleByLabels: jest.fn(),
findScoreRuleById: jest.fn(),
findScoreRules: jest.fn(),
getNextScoreRuleSortOrder: jest.fn(),
reorderScoreRules: jest.fn(),
updateScoreRule: jest.fn(),
},
}));
@ -32,6 +35,9 @@ const mockCreateManyRecords = jest.mocked(workRecordRepository.createManyRecords
const mockCreateRecord = jest.mocked(workRecordRepository.createRecord);
const mockFindScoreRuleById = jest.mocked(workRecordRepository.findScoreRuleById);
const mockFindScoreRules = jest.mocked(workRecordRepository.findScoreRules);
const mockUpdateScoreRule = jest.mocked(workRecordRepository.updateScoreRule);
const mockDeactivateScoreRule = jest.mocked(workRecordRepository.deactivateScoreRule);
const mockReorderScoreRules = jest.mocked(workRecordRepository.reorderScoreRules);
const supervisor = {
id: "supervisor-1",
@ -108,6 +114,131 @@ describe("resolveCurrentMonthQuery", () => {
);
});
it("lets a supervisor edit a rule name and score after checking the active key", async () => {
const existing = {
id: "score-edit",
primary: "旧一级",
secondary: "旧二级",
unitScore: new Prisma.Decimal("3"),
sortOrder: 5,
};
const updated = {
...existing,
primary: "新一级",
secondary: "新二级",
unitScore: new Prisma.Decimal("6.5"),
};
mockFindScoreRuleById.mockResolvedValue(existing as never);
mockFindActiveScoreRuleByLabels.mockResolvedValue(null);
mockUpdateScoreRule.mockResolvedValue(updated as never);
await expect(
workRecordService.updateScoreRule(supervisor, existing.id, {
primary: updated.primary,
secondary: updated.secondary,
unitScore: 6.5,
}),
).resolves.toEqual({
id: updated.id,
primary: updated.primary,
secondary: updated.secondary,
unitScore: 6.5,
sortOrder: updated.sortOrder,
});
expect(mockUpdateScoreRule).toHaveBeenCalledWith(
existing.id,
expect.objectContaining({
primary: updated.primary,
secondary: updated.secondary,
unitScore: new Prisma.Decimal("6.5"),
}),
);
});
it("rejects an edited rule that duplicates another active primary and secondary", async () => {
const existing = {
id: "score-edit",
primary: "旧一级",
secondary: "旧二级",
unitScore: new Prisma.Decimal("3"),
sortOrder: 5,
};
mockFindScoreRuleById.mockResolvedValue(existing as never);
mockFindActiveScoreRuleByLabels.mockResolvedValue({
...existing,
id: "score-duplicate",
} as never);
await expect(
workRecordService.updateScoreRule(supervisor, existing.id, {
primary: "重复一级",
secondary: "重复二级",
}),
).rejects.toMatchObject({ message: "该一级、二级分值规则已存在" });
expect(mockUpdateScoreRule).not.toHaveBeenCalled();
});
it("deactivates a score rule without deleting historical work records", async () => {
mockFindScoreRuleById.mockResolvedValue({
id: "score-remove",
primary: "不再使用",
secondary: "规则",
unitScore: new Prisma.Decimal("1"),
sortOrder: 6,
} as never);
mockDeactivateScoreRule.mockResolvedValue({ id: "score-remove" } as never);
await expect(
workRecordService.deleteScoreRule(supervisor, "score-remove"),
).resolves.toEqual({ id: "score-remove" });
expect(mockDeactivateScoreRule).toHaveBeenCalledWith("score-remove");
});
it("persists a supervisor's complete score-rule order", async () => {
const rules = [
{
id: "score-2",
primary: "第二项",
secondary: "二级",
unitScore: new Prisma.Decimal("2"),
sortOrder: 1,
},
{
id: "score-1",
primary: "第一项",
secondary: "二级",
unitScore: new Prisma.Decimal("1"),
sortOrder: 2,
},
];
mockFindScoreRules.mockResolvedValue(rules as never);
mockReorderScoreRules.mockResolvedValue(undefined);
await expect(
workRecordService.reorderScoreRules(supervisor, ["score-2", "score-1"]),
).resolves.toEqual([
{
id: "score-2",
primary: "第二项",
secondary: "二级",
unitScore: 2,
sortOrder: 1,
},
{
id: "score-1",
primary: "第一项",
secondary: "二级",
unitScore: 1,
sortOrder: 2,
},
]);
expect(mockReorderScoreRules).toHaveBeenCalledWith(["score-2", "score-1"]);
});
it("rejects a sample-order record without an SPU", async () => {
mockFindScoreRuleById.mockResolvedValue({
id: "sample-rule",

View File

@ -53,6 +53,8 @@ export type WorkRecordScoreRuleCreateInput = {
unitScore?: unknown;
};
export type WorkRecordScoreRuleUpdateInput = WorkRecordScoreRuleCreateInput;
type WorkRecordActor = IntakeNonIntegratedPermissionUser & {
id: string;
name: string;
@ -591,17 +593,103 @@ class WorkRecordService {
return (await workRecordRepository.findScoreRules()).map(toScoreRuleDto);
}
async updateScoreRule(user: WorkRecordActor, id: string, unitScore: unknown) {
async updateScoreRule(
user: WorkRecordActor,
id: string,
input: WorkRecordScoreRuleUpdateInput,
) {
assertCanManageScores(user);
await this.ensureScoreRules();
const normalized = normalizeUnitScore(unitScore);
const updated = await workRecordRepository.updateScoreRule(
id,
normalized === null ? null : new Prisma.Decimal(normalized),
const existing = await workRecordRepository.findScoreRuleById(id);
if (!existing) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, "产品工作分数规则不存在");
}
const primary =
input.primary === undefined ? existing.primary : normalizeText(input.primary);
const secondary =
input.secondary === undefined
? existing.secondary
: normalizeText(input.secondary);
if (!primary || !secondary) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"一级和二级不能为空",
);
}
const duplicate = await workRecordRepository.findActiveScoreRuleByLabels(
primary,
secondary,
);
if (duplicate && duplicate.id !== id) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"该一级、二级分值规则已存在",
);
}
const unitScore = normalizeUnitScore(
input.unitScore === undefined
? existing.unitScore?.toString() ?? null
: input.unitScore,
);
const updated = await workRecordRepository.updateScoreRule(id, {
primary,
secondary,
unitScore: unitScore === null ? null : new Prisma.Decimal(unitScore),
});
return toScoreRuleDto(updated);
}
async deleteScoreRule(user: WorkRecordActor, id: string) {
assertCanManageScores(user);
await this.ensureScoreRules();
const existing = await workRecordRepository.findScoreRuleById(id);
if (!existing) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, "产品工作分数规则不存在");
}
await workRecordRepository.deactivateScoreRule(id);
return { id };
}
async reorderScoreRules(user: WorkRecordActor, ids: unknown) {
assertCanManageScores(user);
await this.ensureScoreRules();
if (!Array.isArray(ids)) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"分值规则排序格式不正确",
);
}
const normalizedIds = ids.map((id) => normalizeText(id));
if (
normalizedIds.some((id) => !id) ||
new Set(normalizedIds).size !== normalizedIds.length
) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"分值规则排序格式不正确",
);
}
const activeRules = await workRecordRepository.findScoreRules();
const activeIds = new Set(activeRules.map((rule) => rule.id));
if (
normalizedIds.length !== activeRules.length ||
normalizedIds.some((id) => !activeIds.has(id))
) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"分值规则已变化,请刷新后重新排序",
);
}
await workRecordRepository.reorderScoreRules(normalizedIds);
return (await workRecordRepository.findScoreRules()).map(toScoreRuleDto);
}
async createScoreRule(
user: WorkRecordActor,
input: WorkRecordScoreRuleCreateInput,