refactor(admin): 接入 PasswordInput,删除分类改用 Modal 确认
- 登录弹窗与系统改密统一用 @stash/ui 的 PasswordInput(改密页补上显隐切换) - 删除分类不再用 window.confirm,改用 Modal + 危险按钮(组件库推荐的组合) - 删除页面私有的 PasswordInput 组件与 .password-field 样式
This commit is contained in:
+2
-13
@@ -1,4 +1,4 @@
|
|||||||
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
||||||
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
@@ -907,18 +907,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 登录密码框 / 条目操作条 / 设置页细节 ---------- */
|
/* ---------- 条目操作条 / 设置页细节 ---------- */
|
||||||
|
|
||||||
.password-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-field > :first-child {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-stat {
|
.item-stat {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
Icon,
|
Icon,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
Notice,
|
Notice,
|
||||||
|
PasswordInput,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -55,6 +57,9 @@ export function AdminPage() {
|
|||||||
const [categories, setCategories] = useState<CategorySummary[]>([])
|
const [categories, setCategories] = useState<CategorySummary[]>([])
|
||||||
const [tab, setTab] = useState('header')
|
const [tab, setTab] = useState('header')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
/** 待删除的分类;非 null 时确认弹窗打开 */
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<CategorySummary | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
|
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
|
||||||
@@ -94,14 +99,21 @@ export function AdminPage() {
|
|||||||
toast.success('已保存')
|
toast.success('已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCategory = async (card: CategorySummary) => {
|
const requestDelete = (card: CategorySummary) => setPendingDelete(card)
|
||||||
if (!window.confirm(`删除「${card.title}」会同时删除它下面的所有条目,确定继续?`)) return
|
|
||||||
const result = await deleteCategory(card.id)
|
const confirmDelete = async () => {
|
||||||
|
if (pendingDelete === null) return
|
||||||
|
setDeleting(true)
|
||||||
|
const result = await deleteCategory(pendingDelete.id)
|
||||||
|
setDeleting(false)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error)
|
toast.error(result.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.success(`已删除「${card.title}」`)
|
|
||||||
|
toast.success(`已删除「${pendingDelete.title}」`)
|
||||||
|
setPendingDelete(null)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +191,7 @@ export function AdminPage() {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
value={draft.sections}
|
value={draft.sections}
|
||||||
onChange={(sections) => setDraft({ ...draft, sections })}
|
onChange={(sections) => setDraft({ ...draft, sections })}
|
||||||
onDelete={removeCategory}
|
onDelete={requestDelete}
|
||||||
onReload={load}
|
onReload={load}
|
||||||
/>
|
/>
|
||||||
) : tab === 'sidebar' ? (
|
) : tab === 'sidebar' ? (
|
||||||
@@ -187,7 +199,7 @@ export function AdminPage() {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
value={draft.sidebar}
|
value={draft.sidebar}
|
||||||
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
||||||
onDelete={removeCategory}
|
onDelete={requestDelete}
|
||||||
onReload={load}
|
onReload={load}
|
||||||
/>
|
/>
|
||||||
) : tab === 'profile' ? (
|
) : tab === 'profile' ? (
|
||||||
@@ -228,6 +240,32 @@ export function AdminPage() {
|
|||||||
void load()
|
void load()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!deleting) setPendingDelete(null)
|
||||||
|
}}
|
||||||
|
title={pendingDelete === null ? '删除分类' : `删除「${pendingDelete.title}」?`}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<Space gap="sm" justify="end">
|
||||||
|
<Button variant="secondary" disabled={deleting} onClick={() => setPendingDelete(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deleting}
|
||||||
|
icon={<Icon name="delete_2_line" />}
|
||||||
|
onClick={() => void confirmDelete()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>删除后,该分类下的所有条目会一起删除,且不可恢复。</Text>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -663,17 +701,17 @@ function SystemPanel() {
|
|||||||
return (
|
return (
|
||||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
<FormField label="当前密码" htmlFor="current-password">
|
<FormField label="当前密码" htmlFor="current-password">
|
||||||
<Input
|
<PasswordInput
|
||||||
id="current-password"
|
id="current-password"
|
||||||
type="password"
|
autoComplete="current-password"
|
||||||
value={current}
|
value={current}
|
||||||
onChange={(event) => setCurrent(event.target.value)}
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
|
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
|
||||||
<Input
|
<PasswordInput
|
||||||
id="next-password"
|
id="next-password"
|
||||||
type="password"
|
autoComplete="new-password"
|
||||||
value={next}
|
value={next}
|
||||||
onChange={(event) => setNext(event.target.value)}
|
onChange={(event) => setNext(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button, FormField, Icon, Modal, Notice, Space, Text } from '@stash/ui'
|
import { Button, FormField, Icon, Modal, Notice, PasswordInput, Space, Text } from '@stash/ui'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { login, setup, type SessionState } from '../../auth'
|
import { login, setup, type SessionState } from '../../auth'
|
||||||
import { PasswordInput } from './PasswordInput'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
|
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
|
||||||
@@ -88,10 +87,13 @@ export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogP
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="login-password"
|
id="login-password"
|
||||||
placeholder="输入密码"
|
placeholder="输入密码"
|
||||||
|
autoComplete={isSetup ? 'new-password' : 'current-password'}
|
||||||
value={password}
|
value={password}
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={setPassword}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
onEnter={() => void submit()}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void submit()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -100,9 +102,12 @@ export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogP
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="login-confirm"
|
id="login-confirm"
|
||||||
placeholder="再输入一次"
|
placeholder="再输入一次"
|
||||||
|
autoComplete="new-password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={setConfirm}
|
onChange={(event) => setConfirm(event.target.value)}
|
||||||
onEnter={() => void submit()}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void submit()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Icon, IconButton, Input } from '@stash/ui'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语言无关的密码可见切换:右侧一个眼睛按钮,切换 input type。
|
|
||||||
* 错误提示后再点仍然有效(状态在组件里,不会被错误重置)。
|
|
||||||
*/
|
|
||||||
type PasswordInputProps = {
|
|
||||||
id: string
|
|
||||||
value: string
|
|
||||||
placeholder?: string
|
|
||||||
autoFocus?: boolean
|
|
||||||
onChange: (value: string) => void
|
|
||||||
onEnter?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordInput({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
placeholder,
|
|
||||||
autoFocus = false,
|
|
||||||
onChange,
|
|
||||||
onEnter,
|
|
||||||
}: PasswordInputProps) {
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="password-field">
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
type={visible ? 'text' : 'password'}
|
|
||||||
icon="lock_line"
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') onEnter?.()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label={visible ? '隐藏密码' : '显示密码'}
|
|
||||||
icon={<Icon name={visible ? 'eye_close_line' : 'eye_line'} />}
|
|
||||||
onClick={() => setVisible((current) => !current)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user