feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
37
web/src/components/ui/base/buttons/delete-button.tsx
Normal file
37
web/src/components/ui/base/buttons/delete-button.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button, Popover } from "antd";
|
||||
import { Check, Trash2, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
/** 原位确认只在真实请求完成后关闭;失败保留操作上下文。 */
|
||||
export function DeleteButton({ label, description, onConfirm }: { label: string; description: string; onConfirm: () => Promise<unknown> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const close = () => { if (!busy.current) { setOpen(false); trigger.current?.focus(); } };
|
||||
const confirm = async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try { await onConfirm(); setOpen(false); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败,请重试"); }
|
||||
finally { busy.current = false; setPending(false); }
|
||||
};
|
||||
return <span className="product-delete" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }} onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } }}>
|
||||
<Popover trigger="click" placement="bottomRight" open={open} onOpenChange={(next) => { if (!busy.current) { setOpen(next); setError(""); } }} content={
|
||||
<div className="product-delete-confirm" role="dialog" aria-label={label} aria-busy={pending}>
|
||||
<strong>{label}?</strong>
|
||||
<p>{description}</p>
|
||||
{error ? <p role="alert" className="product-delete-error">{error}</p> : null}
|
||||
<div className="product-delete-confirm-actions">
|
||||
<Button autoFocus icon={<X />} disabled={pending} onClick={close}>取消</Button>
|
||||
<Button danger type="primary" icon={<Check />} loading={pending} onClick={() => void confirm()}>确认删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<button ref={trigger} type="button" className="product-icon-button product-delete-trigger" aria-label={label} aria-expanded={open} aria-haspopup="dialog"><Trash2 /></button>
|
||||
</Popover>
|
||||
</span>;
|
||||
}
|
||||
9
web/src/components/ui/product/media-placeholder.tsx
Normal file
9
web/src/components/ui/product/media-placeholder.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ImageOff, Layers3 } from "lucide-react";
|
||||
|
||||
export function MediaPlaceholder({ failed = false, label }: { failed?: boolean; label?: string }) {
|
||||
const Icon = failed ? ImageOff : Layers3;
|
||||
return <span className="product-media-placeholder" role={failed ? "img" : undefined} aria-label={failed ? "封面加载失败" : undefined}>
|
||||
<span className="product-media-placeholder-icon"><Icon aria-hidden="true" /></span>
|
||||
<span>{label || (failed ? "封面暂不可用" : "从一个想法开始")}</span>
|
||||
</span>;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface SettingsRowProps {
|
||||
*/
|
||||
export function SettingsRow({ label, description, control, align = "center", className, controlClassName }: SettingsRowProps) {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center gap-4 border-b border-foreground/10 py-2.5 last:border-b-0", align === "top" && "items-start", className)}>
|
||||
<div className={cn("flex w-full items-center gap-4 border-b border-foreground/10 py-3.5 last:border-b-0 last:pb-1", align === "top" && "items-start", className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
{description ? <span className="mt-0.5 block text-xs leading-4 text-foreground/55">{description}</span> : null}
|
||||
|
||||
Reference in New Issue
Block a user