feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-16 22:59:45 +08:00
parent f52702b70f
commit 969e2831b2
46 changed files with 5355 additions and 944 deletions

View 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>;
}

View 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>;
}

View File

@@ -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}