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:
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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user