公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,9 @@ export default function AdminField({
|
||||
disabled,
|
||||
className = "",
|
||||
minHeight,
|
||||
embed,
|
||||
error,
|
||||
describedBy,
|
||||
}: {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
@@ -29,27 +32,40 @@ export default function AdminField({
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
/** 标签由外层设置行提供,本组件只保留计数与输入 */
|
||||
embed?: boolean;
|
||||
error?: string;
|
||||
describedBy?: string;
|
||||
}) {
|
||||
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange(e.target.value);
|
||||
|
||||
const invalid = Boolean(error);
|
||||
const described = [describedBy, error ? `${id}-error` : null].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
const count =
|
||||
maxLength != null ? (
|
||||
<span className={`admin-field-count${embed ? " is-infield" : ""}`} aria-hidden>
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="admin-field-head">
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{maxLength != null && (
|
||||
<span className="admin-field-count" aria-hidden>
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hint != null && hint !== "" && (
|
||||
{embed ? null : (
|
||||
<div className="admin-field-head">
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{count}
|
||||
</div>
|
||||
)}
|
||||
{!embed && hint != null && hint !== "" && (
|
||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
|
||||
)}
|
||||
{as === "textarea" ? (
|
||||
<div className="admin-field-shell is-area">
|
||||
<div className={`admin-field-shell is-area${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
|
||||
{embed ? count : null}
|
||||
<textarea
|
||||
id={id}
|
||||
className="admin-field-input"
|
||||
@@ -59,10 +75,12 @@ export default function AdminField({
|
||||
onChange={handle}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={described}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-field-shell">
|
||||
<div className={`admin-field-shell${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
@@ -73,9 +91,17 @@ export default function AdminField({
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={described}
|
||||
/>
|
||||
{embed ? count : null}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="admin-settings-row-error" id={`${id}-error`} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
160
frontend/components/admin/AdminSettingsLayout.tsx
Normal file
160
frontend/components/admin/AdminSettingsLayout.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** 站点设置工作区:限宽、主内容区内左对齐,不写入后台公共主容器 */
|
||||
export function AdminSettingsPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`admin-settings-page${className ? ` ${className}` : ""}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** 分类导航 + 主表单列 */
|
||||
export function AdminSettingsWork({
|
||||
nav,
|
||||
children,
|
||||
}: {
|
||||
nav: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-settings-work">
|
||||
<div className="admin-settings-nav-col">{nav}</div>
|
||||
<div className="admin-settings-main">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一组相关设置:标题、说明、字段、底部操作,减少逐项套卡片 */
|
||||
export function AdminSettingsGroup({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`admin-settings-group${className ? ` ${className}` : ""}`} id={id}>
|
||||
<header className="admin-settings-group-head">
|
||||
<h2 className="admin-settings-group-title">{title}</h2>
|
||||
{description != null && description !== "" ? (
|
||||
<p className="admin-settings-group-desc">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="admin-settings-group-body">{children}</div>
|
||||
{footer != null ? <div className="admin-settings-group-footer">{footer}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签列 + 控件列;窄表单自动改为上下堆叠 */
|
||||
export function AdminSettingsRow({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
hintBelow,
|
||||
error,
|
||||
errorId,
|
||||
children,
|
||||
wide,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
htmlFor?: string;
|
||||
/** 短说明,紧邻标签 */
|
||||
hint?: ReactNode;
|
||||
/** 长说明,放在控件下方 */
|
||||
hintBelow?: ReactNode;
|
||||
error?: ReactNode;
|
||||
errorId?: string;
|
||||
children: ReactNode;
|
||||
/** 简介等较长控件 */
|
||||
wide?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Tag = htmlFor ? "label" : "div";
|
||||
return (
|
||||
<div className={`admin-settings-row${wide ? " is-wide" : ""}${className ? ` ${className}` : ""}`}>
|
||||
<div className="admin-settings-row-label">
|
||||
<Tag className="admin-settings-row-name" {...(htmlFor ? { htmlFor } : {})}>
|
||||
{label}
|
||||
</Tag>
|
||||
{hint != null && hint !== "" ? <p className="admin-settings-row-hint">{hint}</p> : null}
|
||||
</div>
|
||||
<div className="admin-settings-row-control">
|
||||
{children}
|
||||
{hintBelow != null && hintBelow !== "" ? (
|
||||
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
|
||||
) : null}
|
||||
{error != null && error !== "" ? (
|
||||
<p className="admin-settings-row-error" id={errorId} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存单元底部操作:按钮与状态贴齐控件列左边缘 */
|
||||
export function AdminSettingsActions({
|
||||
dirty,
|
||||
saving,
|
||||
saveLabel,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveDisabled,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving?: boolean;
|
||||
saveLabel: string;
|
||||
onSave: () => void;
|
||||
onDiscard?: () => void;
|
||||
saveDisabled?: boolean;
|
||||
error?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const status = saving ? "正在保存…" : error ? error : dirty ? "有未保存的修改" : "已保存";
|
||||
return (
|
||||
<div className="admin-settings-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-settings-actions-inner">
|
||||
<div className="admin-settings-actions-btns">
|
||||
{children}
|
||||
{onDiscard ? (
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={onDiscard}>
|
||||
撤销修改
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saveDisabled ?? (!dirty || saving)}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={`admin-settings-actions-status${error ? " is-error" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ export { default as AdminPageHeader } from "./AdminPageHeader";
|
||||
export { default as AdminSection } from "./AdminSection";
|
||||
export { default as AdminSwitch } from "./AdminSwitch";
|
||||
export { default as AdminSettingRow } from "./AdminSettingRow";
|
||||
export {
|
||||
AdminSettingsPage,
|
||||
AdminSettingsWork,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
AdminSettingsActions,
|
||||
} from "./AdminSettingsLayout";
|
||||
export { default as AdminSegmented } from "./AdminSegmented";
|
||||
export type { AdminSegmentOption } from "./AdminSegmented";
|
||||
export { default as AdminSearchField } from "./AdminSearchField";
|
||||
|
||||
Reference in New Issue
Block a user