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