Merge pull request #6 from klinsmaya/claude/project-thread-liggvu

修复用户反馈:保存误报、改一下可取消、成员自动带部门、S1-02 删关系性能
This commit is contained in:
klinsmaya
2026-11-02 20:15:48 +08:00
committed by GitHub
12 changed files with 268 additions and 62 deletions
@@ -172,6 +172,14 @@ async function upsertMembers(ctx: Context, projectId: number, spaceName: string,
for (const m of list) {
const existing = await repo.findOne({ filter: { projectId, userId: m.userId }, transaction });
added.push({ userId: m.userId, projectRole: m.projectRole, isNew: !existing });
// A new member with no department takes the one they already have in the enterprise's other projects.
if (!existing && !m.orgUnitId) {
const known = await repo.find({ filter: { userId: m.userId, spaceName, orgUnitId: { $notEmpty: true } }, transaction });
const counts = new Map<number, number>();
for (const k of known) counts.set(k.get('orgUnitId'), (counts.get(k.get('orgUnitId')) ?? 0) + 1);
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
if (best) m.orgUnitId = best;
}
if (existing) {
await existing.update({ projectRole: m.projectRole, orgUnitId: m.orgUnitId ?? null }, { transaction });
} else {
+17
View File
@@ -27,3 +27,20 @@ export function memberWarnings(rows: MemberRow[], service: "guided" | "self" = "
if (new Set(ids).size !== ids.length) warnings.push("同一个人出现了多次,保存时以最后一行为准。");
return warnings;
}
/**
* The department a person is placed in when added to a project: the one they already have in the
* enterprise's other projects (the most common one), else the department they head.
*/
export function defaultOrgUnitId(
userId: number,
otherProjects: { members: { userId: number; orgUnitId: number | null }[] }[],
orgUnits: { id: number; headId?: number | null }[],
): number | null {
const counts = new Map<number, number>();
for (const project of otherProjects)
for (const m of project.members) if (m.userId === userId && m.orgUnitId) counts.set(m.orgUnitId, (counts.get(m.orgUnitId) ?? 0) + 1);
const known = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
if (known && orgUnits.some((u) => u.id === known)) return known;
return orgUnits.find((u) => u.headId === userId)?.id ?? null;
}
+12 -3
View File
@@ -89,11 +89,20 @@ function useInvalidateProject(project: ProjectSummary | undefined) {
};
}
export function useSaveArtifact(project: ProjectSummary | undefined, code: string) {
type SaveValues = { payload: unknown; baseRev: number; note?: string; fromProposalId?: number; aiSuggested?: boolean; changes?: unknown[] };
/**
* `onSaved` runs the moment the server accepts the save, before the artifact is refetched: the page
* must learn the new revision first, or the refetch would look like somebody else's change.
*/
export function useSaveArtifact(project: ProjectSummary | undefined, code: string, onSaved?: (result: { rev: number }, values: SaveValues) => void) {
const invalidate = useInvalidateProject(project);
return useMutation({
mutationFn: (values: { payload: unknown; baseRev: number; note?: string; fromProposalId?: number; aiSuggested?: boolean; changes?: unknown[] }) =>
saveArtifact(project!.spaceName, { projectId: project!.id, code, ...values }),
mutationFn: async (values: SaveValues) => {
const result = await saveArtifact(project!.spaceName, { projectId: project!.id, code, ...values });
onSaved?.(result, values);
return result;
},
onSuccess: () => invalidate(code),
});
}
+22
View File
@@ -0,0 +1,22 @@
// jsonb in the database does not keep key order, so a saved payload comes back with the same data in a
// different key order. Comparing with plain JSON.stringify then reports “未保存” forever after a save.
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = sortKeys((value as Record<string, unknown>)[key]);
return out;
}
return value;
}
/** JSON text that does not depend on key order (undefined fields are dropped, as JSON.stringify does). */
export function stableStringify(value: unknown): string {
return JSON.stringify(sortKeys(value)) ?? "undefined";
}
/** Same data, whatever the key order. */
export function samePayload(a: unknown, b: unknown): boolean {
return a === b || stableStringify(a) === stableStringify(b);
}
+8 -3
View File
@@ -29,7 +29,7 @@ import { errorMessage } from "@/lib/dingze/errors";
import { type EnterpriseDetail, type OpsProject, opsApi } from "@/lib/dingze/ops-api";
import { useOpsMutation } from "@/lib/dingze/ops-queries";
import { flattenOrgUnits, orgUnitPath } from "@/lib/dingze/org-units";
import { type MemberRow, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
import { type MemberRow, defaultOrgUnitId, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
import { SCENE_LABELS, SCHEDULE_LABELS } from "./labels";
@@ -340,7 +340,12 @@ function MembersDialog({
const addRow = (userId: number) =>
setRows([
...rows,
{ userId, projectRole: isConsultantAccount(userId) ? "co_consultant" : "member", orgUnitId: null },
{
userId,
projectRole: isConsultantAccount(userId) ? "co_consultant" : "member",
// 自动带上所属部门:本企业其他项目里已有的部门,或其负责的部门。
orgUnitId: isConsultantAccount(userId) ? null : defaultOrgUnitId(userId, detail.projects, detail.orgUnits),
},
]);
const save = async () => {
@@ -596,7 +601,7 @@ function OwnProjectDialog({ open, detail, onClose }: { open: boolean; detail: En
</Table>
</div>
<div className="flex flex-wrap items-center gap-2">
<NativeSelect aria-label="添加成员" value="" onChange={(e) => e.target.value && setRows([...rows, { userId: Number(e.target.value), projectRole: "member", orgUnitId: null }])} disabled={available.length === 0}>
<NativeSelect aria-label="添加成员" value="" onChange={(e) => e.target.value && setRows([...rows, { userId: Number(e.target.value), projectRole: "member", orgUnitId: defaultOrgUnitId(Number(e.target.value), detail.projects, detail.orgUnits) }])} disabled={available.length === 0}>
<NativeSelectOption value="">{available.length ? "+ 添加本企业成员…" : "本企业的账号都已加入"}</NativeSelectOption>
{available.map((m) => (
<NativeSelectOption key={m.id} value={m.id}>
+19 -2
View File
@@ -246,6 +246,8 @@ function PendingCard({
setDecided(true);
fn();
};
// 改一下 is not a decision: the card stays open, can be cancelled, and 确认修改 stays available.
const [revising, setRevising] = useState(false);
const key = JSON.stringify(built?.items ?? []);
useEffect(() => {
if (!session || !built || built.invalid) return;
@@ -361,8 +363,18 @@ function PendingCard({
<Button size="sm" disabled={disabled || decided || !!built?.invalid} onClick={decide(() => onApprove())}>
<Check /> 确认修改
</Button>
<Button size="sm" variant="outline" disabled={disabled || decided} onClick={decide(() => onRevise())}>
<PencilLine /> 改一下
<Button
size="sm"
variant={revising ? "secondary" : "outline"}
disabled={disabled || decided}
aria-pressed={revising}
onClick={() => {
if (revising) return setRevising(false);
setRevising(true);
onRevise();
}}
>
<PencilLine /> {revising ? "取消改一下" : "改一下"}
</Button>
<Button size="sm" variant="ghost" disabled={disabled || decided} onClick={decide(() => onReject("用户不采纳这条建议"))}>
不采纳
@@ -373,6 +385,11 @@ function PendingCard({
</Button>
) : null}
</div>
{revising ? (
<p className="text-[11px]" style={{ color: "var(--dz-ai)" }} role="status">
正在改这条建议:请在下面的对话框说明怎么改,数字咨询师会重出修改卡。不改了就点“取消改一下”,仍可直接“确认修改”。
</p>
) : null}
<p className="text-[11px] text-muted-foreground">确认后写入草稿、记入“修改”,可撤销;保存后生效。</p>
</div>
);
@@ -1,5 +1,5 @@
import { Network, Table2 } from "lucide-react";
import { useState } from "react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import {
type Issue,
@@ -39,8 +39,26 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
const links = value.links ?? [];
const seed = upstream["S1-01"] ? seedStrategyMap(upstream["S1-01"]) : null;
// Row callbacks read the latest value through a ref: they stay the same function between renders (so the
// relation rows can be memoized — a long list of selects made every delete slow) and two quick clicks never
// work from a stale list.
const latest = useRef({ value, onChange });
latest.current = { value, onChange };
const setObjectives = (next: MapObjective[]) => onChange({ ...value, objectives: next });
const setLinks = (next: MapLink[]) => onChange({ ...value, links: next });
const patchLink = useCallback((id: string, patch: Partial<MapLink>) => {
const { value: v, onChange: change } = latest.current;
change({ ...v, links: patchById(v.links ?? [], id, patch) });
}, []);
const removeLink = useCallback((id: string) => {
const { value: v, onChange: change } = latest.current;
change({ ...v, links: removeById(v.links ?? [], id) });
}, []);
const moveLink = useCallback((id: string, delta: -1 | 1) => {
const { value: v, onChange: change } = latest.current;
change({ ...v, links: moveById(v.links ?? [], id, delta) });
}, []);
const groups = useMemo(() => PERSPECTIVES.map((p) => ({ key: p.key, label: p.label, items: objectives.filter((o) => o.perspective === p.key) })), [objectives]);
const removeObjective = (id: string) =>
onChange({ objectives: removeById(objectives, id), links: links.filter((l) => l.from !== id && l.to !== id) });
@@ -129,30 +147,7 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
) : (
<ul className="overflow-hidden rounded-xl border bg-card">
{links.map((link) => (
<li
key={link.id}
data-anchor={link.id}
className={cn(
"grid items-center gap-2 border-b px-3 py-2 last:border-b-0 md:grid-cols-[1fr_auto_1fr_auto]",
invalid.has(link.id) && "bg-destructive/5"
)}
>
<ObjectiveSelect label="起点目标" objectives={objectives} value={link.from} disabled={readOnly} onChange={(from) => setLinks(patchById(links, link.id, { from }))} />
<NativeSelect
aria-label="关系类型"
value={link.kind}
disabled={readOnly}
onChange={(e) => setLinks(patchById(links, link.id, { kind: e.target.value as MapLink["kind"] }))}
>
{Object.entries(KIND_LABELS).map(([k, label]) => (
<NativeSelectOption key={k} value={k}>
{label} →
</NativeSelectOption>
))}
</NativeSelect>
<ObjectiveSelect label="终点目标" objectives={objectives} value={link.to} disabled={readOnly} onChange={(to) => setLinks(patchById(links, link.id, { to }))} />
<RowActions label="这条关系" readOnly={readOnly} onRemove={() => setLinks(removeById(links, link.id))} onMove={(delta) => setLinks(moveById(links, link.id, delta))} />
</li>
<LinkRow key={link.id} link={link} groups={groups} invalid={invalid.has(link.id)} readOnly={readOnly} onPatch={patchLink} onRemove={removeLink} onMove={moveLink} />
))}
</ul>
)}
@@ -163,31 +158,52 @@ export function StrategyMapEditor({ value, onChange, readOnly, issues, upstream
);
}
function ObjectiveSelect({
label,
objectives,
value,
onChange,
disabled,
type Group = { key: string; label: string; items: MapObjective[] };
const LinkRow = memo(function LinkRow({
link,
groups,
invalid,
readOnly,
onPatch,
onRemove,
onMove,
}: {
label: string;
objectives: MapObjective[];
value: string;
onChange: (id: string) => void;
disabled: boolean;
link: MapLink;
groups: Group[];
invalid: boolean;
readOnly: boolean;
onPatch: (id: string, patch: Partial<MapLink>) => void;
onRemove: (id: string) => void;
onMove: (id: string, delta: -1 | 1) => void;
}) {
return (
<li data-anchor={link.id} className={cn("grid items-center gap-2 border-b px-3 py-2 last:border-b-0 md:grid-cols-[1fr_auto_1fr_auto]", invalid && "bg-destructive/5")}>
<ObjectiveSelect label="起点目标" groups={groups} value={link.from} disabled={readOnly} onChange={(from) => onPatch(link.id, { from })} />
<NativeSelect aria-label="关系类型" value={link.kind} disabled={readOnly} onChange={(e) => onPatch(link.id, { kind: e.target.value as MapLink["kind"] })}>
{Object.entries(KIND_LABELS).map(([k, label]) => (
<NativeSelectOption key={k} value={k}>
{label} →
</NativeSelectOption>
))}
</NativeSelect>
<ObjectiveSelect label="终点目标" groups={groups} value={link.to} disabled={readOnly} onChange={(to) => onPatch(link.id, { to })} />
<RowActions label="这条关系" readOnly={readOnly} onRemove={() => onRemove(link.id)} onMove={(delta) => onMove(link.id, delta)} />
</li>
);
});
function ObjectiveSelect({ label, groups, value, onChange, disabled }: { label: string; groups: Group[]; value: string; onChange: (id: string) => void; disabled: boolean }) {
return (
<NativeSelect aria-label={label} className="w-full" value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
<NativeSelectOption value="">选择目标…</NativeSelectOption>
{PERSPECTIVES.map((p) => (
<NativeSelectOptGroup key={p.key} label={p.label}>
{objectives
.filter((o) => o.perspective === p.key)
.map((o) => (
<NativeSelectOption key={o.id} value={o.id}>
{o.title || "(未命名)"}
</NativeSelectOption>
))}
{groups.map((g) => (
<NativeSelectOptGroup key={g.key} label={g.label}>
{g.items.map((o) => (
<NativeSelectOption key={o.id} value={o.id}>
{o.title || "(未命名)"}
</NativeSelectOption>
))}
</NativeSelectOptGroup>
))}
</NativeSelect>
+2 -1
View File
@@ -1,3 +1,4 @@
import { samePayload } from "@/lib/dingze/stable-json";
import { CheckCheck, ClipboardPaste, Download, Eye, FileCheck2, FileUp, Save, ShieldCheck, Sparkles, Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useGetIdentity } from "@refinedev/core";
@@ -277,7 +278,7 @@ export default function MaterialsPage() {
const [items, setItems] = useState<ProfileItem[]>([]);
const [baseRev, setBaseRev] = useState(0);
const serverItems = profile.data?.items;
const dirty = JSON.stringify(items) !== JSON.stringify(serverItems ?? []);
const dirty = !samePayload(items, serverItems ?? []);
useEffect(() => {
if (!profile.data) return;
if (!dirty || profile.data.rev !== baseRev) {
+21 -7
View File
@@ -50,6 +50,7 @@ import { EDITABLE_CODES, emptyPayload } from "@/lib/dingze/artifact-payloads";
import { type ProposedChange, applyChanges } from "@/lib/dingze/changes";
import { workflowContext } from "@/lib/dingze/coach-context";
import { usePersistentFlag } from "@/lib/dingze/view-prefs";
import { samePayload } from "@/lib/dingze/stable-json";
import { PROPOSE_TOOL } from "@/lib/dingze/coach";
import { errorMessage } from "@/lib/dingze/errors";
@@ -81,8 +82,9 @@ import { ArtifactEditor } from "./editors/artifact-editor";
import { useProjectContext } from "./project-context";
type AsideTab = "coach" | "comments" | "changes" | "book";
type EditState = { payload: unknown; log: Command[]; redo: Command[]; saved: Command[] };
const freshEdit = (payload: unknown): EditState => ({ payload, log: [], redo: [], saved: [] });
/** `base` is what the server has (after our own save: what we saved), so “未保存” is draft vs base. */
type EditState = { payload: unknown; log: Command[]; redo: Command[]; saved: Command[]; base: unknown };
const freshEdit = (payload: unknown): EditState => ({ payload, log: [], redo: [], saved: [], base: payload });
const ENTERPRISE_ROLES = ["ent_lead", "dept_head", "member"];
@@ -121,7 +123,21 @@ function Workspace({ code }: { code: string }) {
const { project, overview, topbar } = useProjectContext();
const def = getArtifactDef(code);
const detail = useArtifactDetail(project, code);
const save = useSaveArtifact(project, code);
// Our own save: learn the new revision and what was saved before the refetch lands, so edits typed
// meanwhile stay as unsaved and the refetch is not mistaken for someone else's change.
const [baseRev, setBaseRev] = useState(0);
const savingLog = useRef<Command[]>([]);
const save = useSaveArtifact(project, code, (result, values) => {
const done = new Set(savingLog.current.map((c) => c.id));
setBaseRev(result.rev);
setEdit((e) => ({
...e,
base: values.payload,
log: e.log.filter((c) => !done.has(c.id)),
redo: [],
saved: [...e.saved, ...savingLog.current].slice(-50),
}));
});
const liveUpstream = useUpstreamPayloads(project, [...def.dependsOn, ...(def.reads ?? [])]).payloads;
const artifact = detail.data?.artifact ?? null;
@@ -136,8 +152,7 @@ function Workspace({ code }: { code: string }) {
// commands, and commands saved in this visit can still be undone (as a new 撤销 command).
const [edit, setEdit] = useState<EditState>(() => freshEdit(serverPayload));
const draft = edit.payload;
const [baseRev, setBaseRev] = useState(serverRev);
const dirty = JSON.stringify(draft) !== JSON.stringify(serverPayload);
const dirty = useMemo(() => !samePayload(draft, edit.base), [draft, edit.base]);
const [activeComment, setActiveComment] = useState<number | null>(null);
const activeCommentRef = useRef(activeComment);
activeCommentRef.current = activeComment;
@@ -281,12 +296,11 @@ function Workspace({ code }: { code: string }) {
const addComment = useAddComment(project, code);
const onSave = () => {
const log = edit.log;
savingLog.current = log;
save.mutate(
{ payload: draft, baseRev, changes: log.map(toSaved), aiSuggested: log.some((c) => c.via === "chat") || undefined },
{
onSuccess: (result) => {
setBaseRev(result.rev);
setEdit((e) => ({ ...e, log: [], redo: [], saved: [...e.saved, ...log].slice(-50) }));
toast.success(`已保存 v${result.rev}`);
// Edits made 按批注 answer their comment thread.
const byComment = new Map<number, string[]>();
@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import type { GoalPathSystem, StrategyMap } from "@dingze/shared";
import { PathSystemEditor } from "@/pages/projects/editors/path-system-editor";
import { StrategyMapEditor } from "@/pages/projects/editors/strategy-map-editor";
function PathHost({ initial, view }: { initial: GoalPathSystem; view: "t310" | "mind" }) {
const [value, setValue] = useState(initial);
return (
<>
<button type="button" onClick={() => setValue({ ...value, nodes: value.nodes.map((n) => (n.id === "a" ? { ...n, value: "800", amount: 800 } : n)) })}>external fix</button>
<PathSystemEditor
value={value}
onChange={setValue}
readOnly={false}
issues={[]}
goalWord="年度目标"
goals={[{ id: "g1", label: "营收", metric: "营收", value: "1000", unit: "万元" }]}
decodeLayout
graphSpecId="S2-02"
defaultView={view}
/>
</>
);
}
const paths = (): GoalPathSystem => ({
nodes: [
{ id: "a", parentId: null, goalId: "g1", level: 1, path: "开发新客户", metric: "营收", value: "1000", amount: 1000, unit: "万元", perspective: "financial" },
{ id: "a1", parentId: "a", goalId: null, level: 2, path: "华东", metric: "营收", value: "300", amount: 300, unit: "万元" },
{ id: "a2", parentId: "a", goalId: null, level: 2, path: "华南", metric: "营收", value: "500", amount: 500, unit: "万元" },
],
} as GoalPathSystem);
describe("one-click fix in 表 3-10 refreshes the sums", () => {
it("updates the check row and the mind map right after the fix", () => {
render(<PathHost initial={paths()} view="t310" />);
const fix = screen.getByRole("button", { name: /把上级改为 800/ });
fireEvent.click(fix);
expect(screen.queryByRole("button", { name: /把上级改为 800/ })).toBeNull();
expect(screen.getByText(/800 = 300 \+ 500/)).toBeInTheDocument();
});
it("mind map sums follow an external change", () => {
render(<PathHost initial={paths()} view="mind" />);
expect(screen.getAllByText(/1000 ≠ 300 \+ 500|≠/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("external fix"));
expect(screen.getAllByText(/800 = 300 \+ 500|300 \+ 500 = 800/).length).toBeGreaterThan(0);
});
});
function MapHost({ initial }: { initial: StrategyMap }) {
const [value, setValue] = useState(initial);
return <StrategyMapEditor value={value} onChange={setValue} readOnly={false} issues={[]} upstream={{}} />;
}
describe("strategy map relation removal", () => {
it("removes the relation row at once", () => {
render(
<MapHost
initial={{
objectives: [
{ id: "o1", perspective: "financial", title: "营收", note: "" },
{ id: "o2", perspective: "customer", title: "客户", note: "" },
],
links: [{ id: "r1", from: "o2", to: "o1", kind: "cause" }],
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "删除 这条关系" }));
expect(screen.getByText("还没有关系")).toBeInTheDocument();
});
});
+9 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { type MemberRow, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
import { type MemberRow, defaultOrgUnitId, diffMembers, memberWarnings } from "@/lib/dingze/project-members";
const row = (userId: number, projectRole: MemberRow["projectRole"], orgUnitId: number | null = null): MemberRow => ({
userId,
@@ -26,4 +26,12 @@ describe("project member editing", () => {
]);
expect(memberWarnings([row(1, "lead_consultant"), row(2, "ent_lead")])).toEqual([]);
});
it("places a newly added member in the department they already have, else the one they head", () => {
const other = [{ members: [{ userId: 7, orgUnitId: 5 }, { userId: 7, orgUnitId: 5 }, { userId: 7, orgUnitId: 6 }] }];
expect(defaultOrgUnitId(7, other, [{ id: 5 }, { id: 6 }])).toBe(5);
expect(defaultOrgUnitId(8, other, [{ id: 5, headId: 8 }])).toBe(5);
expect(defaultOrgUnitId(9, other, [{ id: 5 }])).toBeNull();
expect(defaultOrgUnitId(7, other, [{ id: 6 }])).toBe(null);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { samePayload } from "@/lib/dingze/stable-json";
describe("payload comparison", () => {
it("ignores key order, as jsonb does not keep it", () => {
expect(samePayload({ coreUser: { who: "a", need: "b" }, statement: "s" }, { statement: "s", coreUser: { need: "b", who: "a" } })).toBe(true);
});
it("still sees real changes, including array order", () => {
expect(samePayload({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
expect(samePayload({ a: "x" }, { a: "y" })).toBe(false);
});
});