refactor(home-api): 统一首页API响应格式并迁移配套代码

完成首页数据模型的统一重构,具体变更如下:
1.  重构公共首页API接口,移除`playRecommendations`字段,将玩法推荐数据统一放入`experiences`字段
2.  更新前后端类型定义、序列化逻辑与前端页面组件,适配新的API响应结构
3.  为微信登录相关接口添加`trust_env=True`配置,支持企业代理环境并新增`socksio`依赖
4.  新增图片画廊上传组件,重构SingleImageUploader组件支持自定义宽高比
5.  重构Toast与Select组件的实现与样式,统一后台UI设计系统
6.  移除个人中心页面不必要的返回事件与顶部标题组件,优化详情卡片布局
7.  新增微信接口单元测试,更新官方文档与测试用例适配变更
8.  删除过期的文档图片资源
This commit is contained in:
duanshuwen committed 2026-08-20 21:18:27 +08:00
1 parent d3a246a873
commit 6296392e80
27 files changed
+1055 -218

No files matched your search

@@ -89,6 +89,7 @@ export function ConciergeAdvisorEditor({
value={draft.avatar}
onChange={(value) => update("avatar", value ?? "")}
group="concierge"
aspectRatio="square"
/>
</div>
<div>
@@ -97,6 +98,7 @@ export function ConciergeAdvisorEditor({
value={draft.qrImage}
onChange={(value) => update("qrImage", value ?? "")}
group="concierge"
aspectRatio="square"
/>
</div>
</div>
@@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type { HomeWanfaRecommendation, WanfaCategory } from "@/api";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export function HomeWanfaRecommendationRail({
@@ -35,20 +35,23 @@ export function HomeWanfaRecommendationRail({
<p>关联玩法分类,维护首页推荐入口和展示顺序。</p>
</span>
<div className="flex items-center gap-2">
<NativeSelect
<Select
aria-label="选择要推荐的玩法分类"
className="h-10 min-w-[148px] py-1.5 text-xs"
value={selectedCategoryId}
disabled={!availableCategories.length || savingKey === "add"}
onChange={(event) => setSelectedCategoryId(event.target.value)}
onValueChange={setSelectedCategoryId}
>
<option value="">关联分类</option>
{availableCategories.map((category) => (
<option value={category.id} key={category.id}>
{category.label}
</option>
))}
</NativeSelect>
<SelectTrigger className="h-10 min-w-[148px] py-1.5 text-xs">
<SelectValue placeholder="关联分类" />
</SelectTrigger>
<SelectContent align="end">
{availableCategories.map((category) => (
<SelectItem value={category.id} key={category.id}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
className="primary-action compact"
@@ -0,0 +1,137 @@
import { ChangeEvent, useRef, useState } from "react";
import { ImagePlus, LoaderCircle, Trash2 } from "lucide-react";
import { uploadMediaAsset } from "@/api";
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export function ImageGalleryUploader({
id,
value,
onChange,
group = "wanfa-detail",
}: {
id: string;
value: string[];
onChange: (value: string[]) => void;
group?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const [uploadingTotal, setUploadingTotal] = useState(0);
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (!files.length) return;
const invalidFile = files.find(
(file) => !ALLOWED_IMAGE_TYPES.has(file.type) || file.size > MAX_IMAGE_BYTES,
);
if (invalidFile) {
setError(
ALLOWED_IMAGE_TYPES.has(invalidFile.type)
? `「${invalidFile.name}」超过 5MB,请压缩后重新上传。`
: `「${invalidFile.name}」不是支持的图片格式,请选择 JPG、PNG、WebP 或 GIF。`,
);
return;
}
setError("");
let nextGallery = [...value];
setUploadingTotal(files.length);
try {
for (const [index, file] of files.entries()) {
setUploadingIndex(index);
const asset = await uploadMediaAsset(file, group);
nextGallery = [...nextGallery, asset.url];
onChange(nextGallery);
}
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试。");
} finally {
setUploadingIndex(null);
setUploadingTotal(0);
}
};
const removeImage = (index: number) => {
onChange(value.filter((_, itemIndex) => itemIndex !== index));
};
const uploadTile = (
<li className="single-image-card">
<label
htmlFor={id}
className={`single-image-preview ${uploadingIndex !== null ? "pointer-events-none opacity-60" : ""}`}
>
<input
ref={inputRef}
id={id}
className="single-image-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
onChange={handleFileChange}
disabled={uploadingIndex !== null}
/>
<span className="single-image-placeholder">
{uploadingIndex === null ? <ImagePlus size={30} /> : <LoaderCircle className="animate-spin" size={30} />}
<b>
{uploadingIndex === null
? value.length
? "继续上传图片"
: "暂无图片"
: `上传中 ${uploadingIndex + 1}/${uploadingTotal}`}
</b>
<small>
{uploadingIndex === null ? "上传后在这里预览详情素材" : "正在上传到媒体资源库"}
</small>
</span>
</label>
</li>
);
return (
<section className="grid gap-3 rounded-xl border border-slate-200 bg-white p-3.5 shadow-sm">
<div className="border-b border-slate-100 bg-slate-50/80 px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900">详情图片画廊</p>
<p className="mt-1 text-xs font-normal leading-5 text-slate-500">
支持多选上传,单张不超过 5MB;图片会按当前顺序保存到路线详情。
</p>
</div>
</div>
<ul className="grid gap-3 px-1" aria-label="详情图片列表">
{value.map((image, index) => (
<li className="single-image-card has-image" key={`${image}-${index}`}>
<div className="single-image-preview has-image">
<img src={image} alt={`详情图片 ${index + 1}`} loading="lazy" />
</div>
<div className="single-image-actions">
<button
type="button"
className="single-image-remove-button"
onClick={() => removeImage(index)}
aria-label={`移除第 ${index + 1} 张图片`}
title={`移除第 ${index + 1} 张图片`}
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
{uploadTile}
</ul>
{error ? (
<p className="text-xs font-medium leading-5 text-rose-600" role="alert">
{error}
</p>
) : null}
</section>
);
}
@@ -10,10 +10,12 @@ export function SingleImageUploader({
value,
onChange,
group = "site-config",
aspectRatio = "landscape",
}: {
value?: string | null;
onChange: (value: string | null) => void;
group?: string;
aspectRatio?: "landscape" | "square";
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState("");
@@ -53,7 +55,7 @@ export function SingleImageUploader({
};
return (
<div className="single-image-uploader">
<div className={`single-image-uploader single-image-uploader--${aspectRatio}`}>
<input
ref={inputRef}
className="single-image-input"
@@ -1,25 +1,30 @@
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Toast } from "@/types/admin";
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from "@/components/ui/toast";
import type { Toast as AdminToast } from "@/types/admin";
export function ToastStack({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
export function ToastStack({ toasts, onClose }: { toasts: AdminToast[]; onClose: (id: string) => void }) {
if (!toasts.length) return null;
return (
<div className="admin-toast-stack" role="status" aria-live="polite">
<ToastViewport>
{toasts.map((toast) => (
<article className={`admin-toast ${toast.tone}`} key={toast.id}>
{toast.tone === "success" ? <CheckCircle2 size={20} /> : <AlertTriangle size={20} />}
<span>
<strong>{toast.title}</strong>
<small>{toast.message}</small>
</span>
<Button variant="ghost" size="icon" onClick={() => onClose(toast.id)} aria-label="关闭提示">
<X size={16} />
</Button>
</article>
<Toast tone={toast.tone} key={toast.id}>
{toast.tone === "success" ? (
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-700" aria-hidden="true" />
) : (
<AlertTriangle
className={`mt-0.5 size-5 shrink-0 ${toast.tone === "danger" ? "text-red-700" : "text-amber-700"}`}
aria-hidden="true"
/>
)}
<div className="min-w-0">
<ToastTitle>{toast.title}</ToastTitle>
<ToastDescription>{toast.message}</ToastDescription>
</div>
<ToastClose onClick={() => onClose(toast.id)} />
</Toast>
))}
</div>
</ToastViewport>
);
}
@@ -1,5 +1,7 @@
import type { DetailCreate } from "@/api";
import { ImageGalleryUploader } from "@/components/admin/ImageGalleryUploader";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
const detailArrayFields = [
@@ -7,7 +9,6 @@ const detailArrayFields = [
["included", "费用包含", "每行填写一项包含内容"],
["excluded", "费用不含", "每行填写一项不包含内容"],
["notes", "注意事项", "每行填写一项注意事项"],
["gallery", "详情图片画廊", "每行填写一个 http(s) 图片地址"],
] as const;
type DetailArrayField = (typeof detailArrayFields)[number][0];
@@ -100,21 +101,39 @@ export function WanfaRouteDetailFields({
<Textarea
id={`wanfa-detail-${key}`}
value={detail[key].join("\n")}
rows={key === "gallery" ? 3 : 4}
rows={4}
placeholder={placeholder}
onChange={(event) => updateLines(key, event.target.value)}
/>
</label>
))}
<label className="flex items-center gap-3 text-sm font-medium text-slate-800 sm:col-span-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-teal-700 focus:ring-teal-600"
checked={detail.isActive}
onChange={(event) => onChange("isActive", event.target.checked)}
<div className="sm:col-span-2">
<ImageGalleryUploader
id="wanfa-detail-gallery"
value={detail.gallery}
onChange={(value) => onChange("gallery", value)}
/>
启用路线详情
</label>
</div>
<div className="sm:col-span-2 rounded-xl border border-slate-200 bg-white p-3.5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p id="wanfa-detail-active-label" className="text-sm font-semibold text-slate-900">
启用路线详情
</p>
<p id="wanfa-detail-active-description" className="mt-1 text-xs leading-5 text-slate-500">
{detail.isActive ? "前台详情页会展示这条路线的完整内容。" : "暂不对前台展示路线详情,可随时重新启用。"}
</p>
</div>
<Switch
id="wanfa-detail-active"
checked={detail.isActive}
onCheckedChange={(checked) => onChange("isActive", checked)}
aria-labelledby="wanfa-detail-active-label"
aria-describedby="wanfa-detail-active-description"
className="data-[state=checked]:bg-teal-700 data-[state=unchecked]:bg-slate-300"
/>
</div>
</div>
</div>
</section>
);
@@ -1,7 +1,129 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-950 shadow-sm outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:border-slate-400 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20 data-[placeholder]:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500 [&>span]:line-clamp-1 [&>span]:flex [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-slate-500" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex h-6 cursor-default items-center justify-center text-slate-500", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex h-6 cursor-default items-center justify-center text-slate-500", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white text-slate-950 shadow-lg outline-none data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
position === "popper" && "translate-y-1",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-xs font-semibold text-slate-500", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-teal-700" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-slate-100", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, children, ...props }, ref) => (
<select
ref={ref}
@@ -17,3 +139,16 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttribu
NativeSelect.displayName = "NativeSelect";
export { NativeSelect };
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,65 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const toastVariants = cva(
"pointer-events-auto relative grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 rounded-xl border p-4 text-sm shadow-lg",
{
variants: {
tone: {
success: "border-emerald-200 bg-emerald-50 text-emerald-950",
warning: "border-amber-200 bg-amber-50 text-amber-950",
danger: "border-red-200 bg-red-50 text-red-950",
},
},
defaultVariants: {
tone: "success",
},
},
);
const Toast = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & VariantProps<typeof toastVariants>>(
({ className, tone, ...props }, ref) => <article ref={ref} role="status" className={cn(toastVariants({ tone }), className)} {...props} />,
);
Toast.displayName = "Toast";
const ToastTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("font-semibold leading-5", className)} {...props} />
));
ToastTitle.displayName = "ToastTitle";
const ToastDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("mt-1 text-sm leading-5 text-slate-600", className)} {...props} />
));
ToastDescription.displayName = "ToastDescription";
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(({ className, ...props }, ref) => (
<button
ref={ref}
type="button"
aria-label="关闭提示"
className={cn(
"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-black/5 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]",
className,
)}
{...props}
>
<X className="size-4" aria-hidden="true" />
</button>
));
ToastClose.displayName = "ToastClose";
const ToastViewport = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div
ref={ref}
role="status"
aria-live="polite"
className={cn("fixed right-4 top-4 z-[80] grid w-[min(360px,calc(100vw-2rem))] gap-3", className)}
{...props}
/>
));
ToastViewport.displayName = "ToastViewport";
export { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport };
+546 -76
View File
@@ -617,6 +617,542 @@ textarea {
}
}
/* Unified visual system: calm teal, clear hierarchy, compact workspace surfaces. */
:root {
--surface-subtle: #f8fafc;
--surface-muted: #f1f5f9;
--text-subtle: #64748b;
--border-soft: #e2e8f0;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.035);
--shadow-popover: 0 18px 48px rgba(15, 23, 42, 0.14);
--radius-panel: 16px;
--radius-control: 10px;
}
html {
background: var(--surface-subtle);
}
body {
min-width: 320px;
color: var(--foreground);
background: var(--surface-subtle);
-webkit-font-smoothing: antialiased;
}
button,
a,
input,
select,
textarea {
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease,
transform 160ms ease;
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
}
.login-shell {
background: var(--surface-subtle);
}
.login-panel {
width: min(420px, calc(100vw - 32px));
gap: 20px;
padding: 32px;
border: 1px solid var(--border-soft);
border-radius: 20px;
box-shadow: var(--shadow-card);
}
.login-panel h3 {
font-size: 24px;
letter-spacing: -0.02em;
}
.login-note {
color: var(--text-subtle);
line-height: 1.6;
}
.admin-shell {
grid-template-columns: 248px minmax(0, 1fr);
background: var(--surface-subtle);
}
.sidebar {
border-right: 1px solid var(--border-soft);
background: #fff;
color: var(--foreground);
box-shadow: 8px 0 28px rgba(15, 23, 42, 0.025);
}
.brand-block {
margin: 0 0 18px;
padding: 14px 12px;
border: 1px solid var(--border-soft);
border-radius: 16px;
background: #fff;
}
.brand-block .brand-mark {
width: 40px;
height: 40px;
border-radius: 12px;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.16);
}
.brand-block > b {
font-size: 17px;
letter-spacing: -0.01em;
}
.sidebar nav {
gap: 8px;
}
.sidebar-group {
gap: 6px;
}
.sidebar-group > small,
.sidebar-group > h4 {
padding-inline: 10px;
color: #94a3b8;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sidebar-group > button {
min-height: 48px;
border: 1px solid transparent;
border-radius: 12px;
color: #334155;
}
.sidebar-group > button:hover {
border-color: #ccfbf1;
background: #f0fdfa;
color: #115e59;
}
.sidebar-group > button.active {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
box-shadow: inset 3px 0 0 #0f766e;
}
.sidebar-group > button > span:last-child {
min-width: 0;
text-align: left;
}
.sidebar-footer {
padding-top: 16px;
border-top: 1px solid var(--border-soft);
}
.main-panel {
min-width: 0;
background: var(--surface-subtle);
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
min-height: 68px;
border-color: var(--border-soft);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.topbar span {
font-size: 18px;
letter-spacing: -0.02em;
}
.topbar small {
color: var(--text-subtle);
line-height: 1.45;
}
.topbar-actions {
gap: 10px;
}
.work-area {
min-width: 0;
padding: clamp(16px, 2.5vw, 28px);
}
.section-head,
.toolbar,
.metric-card,
.data-panel,
.editor-pane,
.list-pane,
.module-rail,
.item-rail,
.front-context {
border-color: var(--border-soft);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-card);
}
.section-head {
padding: 20px;
}
.section-head h2,
.editor-head h3,
.data-panel h3,
.module-rail h3,
.item-rail h3 {
letter-spacing: -0.02em;
}
.section-head p,
.module-data-head p,
.field-help,
.muted,
.empty-state {
color: var(--text-subtle);
}
.home-module-grid,
.maintenance-grid {
gap: 16px;
}
.module-rail,
.item-rail {
padding: 16px;
background: #fff;
}
.module-rail > h3,
.item-rail > header,
.module-data-head {
margin-bottom: 12px;
}
.module-rail-item,
.home-module-item {
min-height: 52px;
border-color: var(--border-soft);
border-radius: 11px;
background: var(--surface-subtle);
}
.module-rail-item:hover,
.home-module-item:hover {
border-color: #99f6e4;
background: #f0fdfa;
}
.module-rail-item.active,
.home-module-item.active {
border-color: #5eead4;
background: #f0fdfa;
box-shadow: 0 0 0 2px rgba(20, 184, 166, 0.1);
}
.module-index,
.module-rail em {
border-radius: 9px;
background: #ecfdf5;
color: #0f766e;
font-weight: 800;
}
.module-data-head {
align-items: flex-start;
gap: 12px;
}
.module-data-head h3 {
font-size: 18px;
}
.module-data-head p {
margin-top: 4px;
line-height: 1.55;
}
.mapped-list {
gap: 10px;
}
.mapped-list-row {
gap: 8px;
min-width: 0;
}
.mapped-list-main {
min-width: 0;
border-color: var(--border-soft);
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
}
.mapped-list-main:hover,
.mapped-list-main.selected {
border-color: #99f6e4;
background: #f0fdfa;
box-shadow: 0 6px 16px rgba(15, 118, 110, 0.08);
}
.mapped-list-actions {
gap: 4px;
}
.mapped-list-actions button,
.module-rail-action,
.icon-action,
.inline-action {
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
border-radius: 9px;
}
.mapped-list-actions button:hover,
.module-rail-action:hover,
.icon-action:hover,
.inline-action:hover {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.mapped-list-actions button:last-child:hover,
.icon-action.danger-icon:hover {
border-color: var(--destructive-border);
background: var(--destructive-muted);
color: var(--destructive);
}
.primary-action {
border-radius: var(--radius-control);
background: var(--primary);
box-shadow: 0 5px 12px rgba(15, 118, 110, 0.14);
}
.primary-action:hover {
background: var(--primary-hover);
box-shadow: 0 7px 16px rgba(15, 118, 110, 0.18);
}
.primary-action.compact {
min-height: 40px;
border-radius: var(--radius-control);
}
input,
select,
textarea {
min-height: 42px;
border-color: var(--input);
border-radius: var(--radius-control);
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.025);
}
input:focus,
select:focus,
textarea:focus {
border-color: #14b8a6;
outline: none;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.14), 0 1px 2px rgba(15, 23, 42, 0.04);
}
input::placeholder,
textarea::placeholder {
color: #94a3b8;
}
label {
color: #475569;
font-weight: 700;
}
.status-chip {
min-height: 26px;
border-radius: 999px;
padding-inline: 10px;
}
.notice,
.form-error {
border-radius: 12px;
line-height: 1.5;
}
.edit-drawer-backdrop {
background: rgba(15, 23, 42, 0.24);
backdrop-filter: blur(4px);
}
.edit-rail.edit-drawer {
border: 1px solid var(--border-soft);
border-right: 0;
border-radius: 20px 0 0 20px;
box-shadow: var(--shadow-popover);
}
.edit-drawer .editor-head {
min-height: 88px;
padding: 18px 20px;
border-color: var(--border-soft);
}
.edit-drawer .editor-head h3 {
font-size: 18px;
}
.edit-drawer .admin-disclosure-stack {
gap: 14px;
padding: 18px 20px 22px;
background: var(--surface-subtle);
}
.edit-drawer .admin-disclosure {
border-color: var(--border-soft);
border-radius: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.edit-drawer .admin-disclosure-head {
min-height: 54px;
padding: 14px 16px;
background: #fff;
}
.edit-drawer .admin-disclosure-body {
padding: 16px;
}
.drawer-editor-footer {
min-height: 76px;
padding: 14px 20px;
border-color: var(--border-soft);
background: rgba(255, 255, 255, 0.96);
}
.single-image-card,
.image-editor-row,
.image-placeholder,
.image-editor-preview {
border-radius: 12px;
}
.switch-line {
border-radius: 11px;
border-color: var(--border-soft);
background: var(--surface-subtle);
}
.toast-viewport,
[role="status"].fixed {
z-index: 80;
}
@media (max-width: 1100px) {
.maintenance-grid,
.home-module-grid {
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 72px minmax(0, 1fr);
}
.sidebar {
padding: 14px 10px;
}
.brand-block {
margin-bottom: 14px;
padding: 10px 6px 14px;
border-color: transparent;
box-shadow: none;
}
.sidebar-group > button {
justify-content: center;
padding-inline: 0;
}
.sidebar-group > button > span:last-child {
display: none;
}
.work-area {
padding: 16px;
}
.topbar {
min-height: 60px;
padding-inline: 16px;
}
.maintenance-grid,
.home-module-grid {
grid-template-columns: minmax(190px, 230px) minmax(0, 1fr);
}
}
@media (max-width: 700px) {
.maintenance-grid,
.home-module-grid {
grid-template-columns: 1fr;
}
.module-rail,
.item-rail {
width: 100%;
}
.topbar span {
font-size: 16px;
}
.topbar small {
max-width: 54vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 560px) {
.edit-rail.edit-drawer {
border-radius: 0;
}
.edit-drawer .editor-head,
.drawer-editor-footer {
padding-inline: 16px;
}
.edit-drawer .admin-disclosure-stack {
padding-inline: 12px;
}
}
@keyframes drawer-slide-in {
from {
transform: translateX(18px);
@@ -1148,60 +1684,6 @@ textarea {
line-height: 1.6;
}
.admin-toast-stack {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 80;
display: grid;
gap: 10px;
width: min(360px, calc(100vw - 36px));
}
.admin-toast {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid #d5dde3;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 46px rgba(24, 34, 42, 0.16);
}
.admin-toast.success svg {
color: #176c68;
}
.admin-toast.warning svg,
.admin-toast.danger svg {
color: #b66b00;
}
.admin-toast span {
display: grid;
gap: 3px;
}
.admin-toast strong {
font-size: 13px;
}
.admin-toast small {
color: #66727b;
line-height: 1.4;
}
.admin-toast button {
width: 24px;
height: 24px;
border: 0;
border-radius: 5px;
background: #f1f4f6;
color: #66727b;
}
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -2217,8 +2699,6 @@ textarea:hover {
@apply border-b border-slate-200 bg-white px-5 py-4;
}
.drawer-editor-footer,
.metric-card strong {
@apply text-2xl font-black text-slate-950;
}
@@ -2257,26 +2737,6 @@ textarea:hover {
@apply border-slate-200 bg-slate-100 text-slate-500;
}
.admin-toast-stack {
@apply right-5 top-5 gap-3;
}
.admin-toast {
@apply min-w-80 rounded-2xl border border-slate-200 bg-white p-4 text-slate-900 shadow-xl;
}
.admin-toast.success {
@apply border-emerald-200 bg-emerald-50;
}
.admin-toast.warning {
@apply border-amber-200 bg-amber-50;
}
.admin-toast.danger {
@apply border-red-200 bg-red-50;
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 72px minmax(0, 1fr);
@@ -2412,6 +2872,16 @@ textarea:hover {
object-fit: cover;
}
.edit-drawer .single-image-uploader--square .single-image-card {
width: min(100%, 280px);
}
.edit-drawer .single-image-uploader--square .single-image-preview {
aspect-ratio: 1;
min-height: 220px;
max-height: 280px;
}
.edit-drawer .switch-line {
min-height: 46px;
justify-content: flex-start;