fix(ui): продолжать частичное сохранение пользователя без дублей
This commit is contained in:
1 parent
02ca48ef9c
commit
4896d8ef6f
9 files changed
+286
-113
No files matched your search
+64
-81
@@ -23,7 +23,6 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
@@ -33,14 +32,16 @@ import { serverListSchema } from "@mmapp/contracts/servers"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { can } from "@/lib/auth"
|
||||
import {
|
||||
userApiError,
|
||||
createAppUser,
|
||||
getAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} from "@/shared/api/users"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
CableIcon,
|
||||
@@ -84,6 +85,10 @@ function UsersContent() {
|
||||
const loadController = useRef<AbortController | null>(null)
|
||||
const loadRevision = useRef(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||
const [saveResume, setSaveResume] = useState(false)
|
||||
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
@@ -109,7 +114,7 @@ function UsersContent() {
|
||||
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setUsersError(error instanceof Error ? error.message : "Не удалось прочитать пользователей.")
|
||||
if (current()) setUsersError(userApiError(error))
|
||||
throw error
|
||||
}),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||
@@ -123,7 +128,7 @@ function UsersContent() {
|
||||
}
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setServersError(error instanceof Error ? error.message : "Не удалось прочитать серверы.")
|
||||
if (current()) setServersError(userApiError(error))
|
||||
throw error
|
||||
}) : Promise.resolve(null),
|
||||
])
|
||||
@@ -144,85 +149,59 @@ function UsersContent() {
|
||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||
const activeCount = users.filter((u) => u.active).length
|
||||
|
||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||
for (const b of prev) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||
await createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId),
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
comment: b.comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (form: AppUserForm) => {
|
||||
if (!writeReady || saving) return
|
||||
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = `u${Date.now()}`
|
||||
const created: AppUser = {
|
||||
id,
|
||||
...form,
|
||||
last: "только что",
|
||||
avatar: userInitials(form.name),
|
||||
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||
}
|
||||
setUsers((prev) => [...prev, created])
|
||||
} else {
|
||||
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
return
|
||||
const id = "u" + Date.now()
|
||||
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
const checkpoint = saveCheckpoint.current ?? { user: editUser }
|
||||
saveCheckpoint.current = checkpoint
|
||||
setSaving(true); setSaveError(null); setOperationNotice(null)
|
||||
try {
|
||||
if (!editUser) {
|
||||
const created = await createAppUser(backendUrl, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(created.id, form.bindings, [])
|
||||
await loadLive()
|
||||
} else {
|
||||
await updateAppUser(backendUrl, editUser.id, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||
await loadLive()
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||
toast.error(msg)
|
||||
const saved = await saveUserSteps(form, checkpoint, {
|
||||
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||
read: id => getAppUser(backendUrl, id, init()),
|
||||
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||
}, init()),
|
||||
}, current, state => {
|
||||
if (!current()) return
|
||||
setSaveBlocked(!!state.createUncertain)
|
||||
if (state.user) {
|
||||
if (!editUser) setSaveResume(true)
|
||||
const confirmed = state.user
|
||||
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||
}
|
||||
})
|
||||
if (!current() || !saved) return
|
||||
setSheetOpen(false); saveCheckpoint.current = null
|
||||
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||
toast.success("Пользователь сохранён.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const detail = userApiError(error)
|
||||
setSaveError(checkpoint.createUncertain
|
||||
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||
: checkpoint.bindingsNeedRead
|
||||
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||
: detail)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +228,7 @@ function UsersContent() {
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (current()) setDeleteError(error instanceof Error ? error.message : "Удаление не подтверждено.")
|
||||
if (current()) setDeleteError(userApiError(error))
|
||||
} finally {
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setDeleting(false)
|
||||
@@ -263,7 +242,7 @@ function UsersContent() {
|
||||
actions={
|
||||
<>
|
||||
{isLive && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить пользователей</Button>}
|
||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пользователя
|
||||
</Button>
|
||||
</>
|
||||
@@ -316,7 +295,7 @@ function UsersContent() {
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading && !usersKnown}
|
||||
actionsDisabled={!writeReady || saving || deleting}
|
||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||
onEdit={(u) => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||
/>
|
||||
</DataPageCard>}
|
||||
@@ -331,8 +310,12 @@ function UsersContent() {
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
saveBlocked={saveBlocked}
|
||||
saveResume={saveResume}
|
||||
onCheckCatalogs={() => void loadLive()}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
|
||||
Reference in new issue
Block a user