feat: 新增客片案例与团队共创模块,重构玩法与配置

- 新增客片案例全流程功能,包含前台页面、管理端配置、后端API、数据库迁移与文档
- 新增团队共创详情页面与对应接口
- 重构玩法模块:将静态playData替换为动态API获取,拆分类型定义到playTypes.ts
- 优化vite构建配置与环境变量处理,调整详情页操作栏文案
- 更新全量相关文档与配图,新增客片案例、团队共创API文档
- 新增测试用例,完善数据归一化逻辑
- 调整路由配置与环境变量示例文件
This commit is contained in:
duanshuwen committed 2026-08-19 20:23:04 +08:00
1 parent cda8069630
commit 7ba92f3c5d
76 files changed
+7970 -137

No files matched your search

@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { BriefcaseBusiness, Home, LogOut, MapPin } from "lucide-react";
import { Headset, Home, List, LogOut } from "lucide-react";
import { clearToken } from "@/api";
import { EmptyState } from "@/components/admin/EmptyState";
import { ToastStack } from "@/components/admin/ToastStack";
import { Button } from "@/components/ui/button";
import { StructurePage } from "@/pages/structure/StructurePage";
import { ConciergePage } from "@/pages/structure/ConciergePage";
import { HomePage } from "@/pages/structure/HomePage";
import { WanfaPage } from "@/pages/structure/WanfaPage";
import type { Tab, Toast } from "@/types/admin";
const sidebarGroups: {
@@ -18,8 +19,8 @@ const sidebarGroups: {
label: "前台页面",
items: [
{ id: "home", label: "首页", icon: Home, description: "" },
{ id: "play", label: "玩法", icon: MapPin, description: "" },
{ id: "butler", label: "管家", icon: BriefcaseBusiness, description: "" },
{ id: "play", label: "玩法", icon: List, description: "" },
{ id: "butler", label: "管家", icon: Headset, description: "" },
],
},
];
@@ -61,6 +62,7 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
<main className="admin-shell">
<aside className="sidebar">
<div className="brand-block">
<span className="brand-mark" aria-hidden="true">万</span>
<b>万趣管理端</b>
</div>
<nav>
@@ -111,13 +113,11 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
</div>
</header>
{activeTab === "home" ? (
<StructurePage key="home-page" fixedPage="home" notify={notify} />
<HomePage key="home-page" notify={notify} />
) : activeTab === "play" ? (
<StructurePage key="play-page" fixedPage="play" notify={notify} />
<WanfaPage key="wanfa-page" notify={notify} />
) : activeTab === "butler" ? (
<section className="work-area">
<EmptyState text="管家页面内容待配置" />
</section>
<ConciergePage key="concierge-page" notify={notify} />
) : null}
</section>
<ToastStack
@@ -0,0 +1,190 @@
import { useState } from "react";
import { Plus, Save, Trash2, X } from "lucide-react";
import type { ConciergeAdvisor, ConciergeAdvisorCreate, ConciergeDetail } from "@/api";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
function advisorDraft(advisor?: ConciergeAdvisor): ConciergeAdvisorCreate {
return {
avatar: advisor?.avatar ?? "",
name: advisor?.name ?? "",
role: advisor?.role ?? "",
details: advisor?.details.map((detail) => ({ ...detail })) ?? [],
qrImage: advisor?.qrImage ?? "",
isActive: advisor?.isActive ?? true,
};
}
export function ConciergeAdvisorEditor({
mode,
initialAdvisor,
saving,
onSave,
onClose,
}: {
mode: "create" | "edit";
initialAdvisor?: ConciergeAdvisor;
saving: boolean;
onSave: (input: ConciergeAdvisorCreate) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState<ConciergeAdvisorCreate>(() => advisorDraft(initialAdvisor));
const [error, setError] = useState("");
const update = <K extends keyof ConciergeAdvisorCreate>(key: K, value: ConciergeAdvisorCreate[K]) => {
setDraft((current) => ({ ...current, [key]: value }));
if (error) setError("");
};
const updateDetail = (index: number, key: keyof ConciergeDetail, value: string) => {
update(
"details",
draft.details.map((detail, detailIndex) =>
detailIndex === index ? { ...detail, [key]: value } : detail,
),
);
};
const submit = () => {
const input: ConciergeAdvisorCreate = {
...draft,
avatar: draft.avatar.trim(),
name: draft.name.trim(),
role: draft.role.trim(),
qrImage: draft.qrImage.trim(),
details: draft.details.map((detail) => ({ icon: detail.icon.trim(), label: detail.label.trim() })),
};
if (!input.avatar || !input.name || !input.role || !input.qrImage) {
setError("请补充头像、姓名、职位和二维码。" );
return;
}
if (input.details.some((detail) => !detail.icon || !detail.label)) {
setError("服务详情的图标和文案都不能为空。" );
return;
}
setError("");
onSave(input);
};
return (
<div className="mapped-editor flex h-full min-h-0 flex-col">
<header className="editor-head">
<span>
<h3>{mode === "create" ? "新增管家顾问" : "编辑管家顾问"}</h3>
<small>保存后,启用的顾问会通过 Public API 展示到 MiniAPP。</small>
</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} aria-label="关闭管家编辑" disabled={saving}>
<X size={18} />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-slate-800">头像</p>
<SingleImageUploader
value={draft.avatar}
onChange={(value) => update("avatar", value ?? "")}
group="concierge"
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-800">二维码</p>
<SingleImageUploader
value={draft.qrImage}
onChange={(value) => update("qrImage", value ?? "")}
group="concierge"
/>
</div>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium text-slate-800">
姓名
<Input value={draft.name} maxLength={80} placeholder="例如:Amanda" onChange={(event) => update("name", event.target.value)} />
</label>
<label className="grid gap-2 text-sm font-medium text-slate-800">
职位
<Input
value={draft.role}
maxLength={120}
placeholder="例如:SENIOR TRAVEL ADVISOR"
onChange={(event) => update("role", event.target.value)}
/>
</label>
</div>
<section className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-slate-900">服务详情</h4>
<p className="mt-1 text-xs text-slate-500">每条详情由图标名称和展示文案组成,可按顺序维护。</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => update("details", [...draft.details, { icon: "", label: "" }])}
>
<Plus size={15} />
添加详情
</Button>
</div>
<div className="mt-4 grid gap-3">
{draft.details.map((detail, index) => (
<div className="grid gap-2 sm:grid-cols-[150px_1fr_auto]" key={`${index}-${detail.icon}`}>
<Input
value={detail.icon}
maxLength={40}
placeholder="图标名,如 calendar"
aria-label={`第${index + 1}条详情图标`}
onChange={(event) => updateDetail(index, "icon", event.target.value)}
/>
<Input
value={detail.label}
maxLength={120}
placeholder="例如:服务经验:8年"
aria-label={`第${index + 1}条详情文案`}
onChange={(event) => updateDetail(index, "label", event.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
aria-label={`删除第${index + 1}条服务详情`}
onClick={() => update("details", draft.details.filter((_, detailIndex) => detailIndex !== index))}
>
<Trash2 size={16} />
</Button>
</div>
))}
{!draft.details.length ? <p className="text-xs text-slate-500">暂未添加服务详情。</p> : null}
</div>
</section>
<label className="mt-5 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-white p-4">
<span>
<b className="block text-sm font-semibold text-slate-900">前台启用</b>
<small className="mt-1 block text-xs text-slate-500">关闭后仍保留在后台,但不会出现在 MiniAPP。</small>
</span>
<Switch checked={Boolean(draft.isActive)} onCheckedChange={(checked) => update("isActive", checked)} />
</label>
{error ? <p className="mt-3 text-sm text-rose-600">{error}</p> : null}
</div>
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
取消
</Button>
<Button type="button" onClick={submit} disabled={saving}>
<Save size={16} />
{saving ? "保存中…" : mode === "create" ? "创建顾问" : "保存修改"}
</Button>
</footer>
</div>
);
}
@@ -0,0 +1,111 @@
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import type { ConciergeAdvisor } from "@/api";
import { Button } from "@/components/ui/button";
export function ConciergeAdvisorRail({
advisors,
onCreate,
onEdit,
onDelete,
onMove,
moving,
}: {
advisors: ConciergeAdvisor[];
onCreate: () => void;
onEdit: (advisor: ConciergeAdvisor) => void;
onDelete: (advisor: ConciergeAdvisor) => void;
onMove: (advisorId: string, direction: -1 | 1) => void;
moving?: boolean;
}) {
return (
<section className="item-rail min-h-[560px]">
<header className="module-data-head">
<span>
<h3>管家顾问</h3>
<p>维护前台管家卡片、服务详情和二维码。</p>
</span>
<Button type="button" className="primary-action compact" onClick={onCreate}>
<Plus size={16} />
新增管家
</Button>
</header>
<div className="mapped-list">
{advisors.map((advisor, index) => (
<article className="mapped-list-row" key={advisor.id}>
<div className="mapped-list-main min-w-0">
{advisor.avatar ? <img src={advisor.avatar} alt="" /> : <span className="image-placeholder" />}
<span className="min-w-0">
<b className="block truncate">{advisor.name}</b>
<small className="block truncate">
{advisor.role} · {advisor.details.length} 条服务详情
</small>
</span>
</div>
<div className="flex items-center gap-1">
<span
className={`hidden rounded-full px-2.5 py-1 text-xs font-semibold sm:inline-flex ${
advisor.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
}`}
>
{advisor.isActive ? "已启用" : "已停用"}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`上移${advisor.name}`}
title={`上移${advisor.name}`}
disabled={index === 0 || moving}
onClick={() => onMove(advisor.id, -1)}
>
<ArrowUp size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`下移${advisor.name}`}
title={`下移${advisor.name}`}
disabled={index === advisors.length - 1 || moving}
onClick={() => onMove(advisor.id, 1)}
>
<ArrowDown size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`编辑${advisor.name}`}
title={`编辑${advisor.name}`}
onClick={() => onEdit(advisor)}
>
<Pencil size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action danger"
aria-label={`删除${advisor.name}`}
title={`删除${advisor.name}`}
onClick={() => onDelete(advisor)}
>
<Trash2 size={15} />
</Button>
</div>
</article>
))}
{!advisors.length ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<p className="text-sm font-medium text-slate-700">暂无管家顾问</p>
<p className="mt-1 text-xs leading-5 text-slate-500">新增顾问后,MiniAPP 会展示已启用的顾问卡片。</p>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,124 @@
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import type { HomeExperience, HomeTeamBuilding, HomeWildArchive } from "@/api";
import { Button } from "@/components/ui/button";
export type HomeContentSection = "experiences" | "teamBuildings" | "wildArchives";
export type HomeContentItem = HomeExperience | HomeTeamBuilding | HomeWildArchive;
const sectionMeta: Record<HomeContentSection, { label: string; description: string; addLabel: string }> = {
experiences: { label: "体验推荐", description: "维护首页体验卡片和需求入口。", addLabel: "新增体验" },
teamBuildings: { label: "团队共创", description: "维护首页团建卡片和需求入口。", addLabel: "新增团建" },
wildArchives: { label: "极境视界", description: "维护首页案例卡片和需求入口。", addLabel: "新增案例" },
};
export function homeContentSectionMeta(section: HomeContentSection) {
return sectionMeta[section];
}
export function HomeContentSectionRail({
section,
items,
onSelect,
}: {
section: HomeContentSection;
items: Record<HomeContentSection, HomeContentItem[]>;
onSelect: (section: HomeContentSection) => void;
}) {
return (
<section className="module-rail">
<h3>首页内容</h3>
{(Object.keys(sectionMeta) as HomeContentSection[]).map((itemSection, index) => {
const meta = sectionMeta[itemSection];
const active = itemSection === section;
return (
<button
type="button"
className={`module-rail-item flex items-center gap-3 text-left${active ? " active" : ""}`}
key={itemSection}
aria-pressed={active}
onClick={() => onSelect(itemSection)}
>
<em>{index + 1}</em>
<span className="min-w-0">
<b className="block">{meta.label}</b>
<small className="block truncate">{items[itemSection].length} 条内容</small>
</span>
</button>
);
})}
</section>
);
}
export function HomeContentCardRail({
section,
items,
onCreate,
onEdit,
onDelete,
onMove,
moving,
}: {
section: HomeContentSection;
items: HomeContentItem[];
onCreate: () => void;
onEdit: (item: HomeContentItem) => void;
onDelete: (item: HomeContentItem) => void;
onMove: (itemId: string, direction: -1 | 1) => void;
moving?: boolean;
}) {
const meta = sectionMeta[section];
return (
<section className="item-rail">
<header className="module-data-head">
<span>
<h3>{meta.label}</h3>
<p>{meta.description}</p>
</span>
<Button type="button" className="primary-action compact" onClick={onCreate}>
<Plus size={16} />
{meta.addLabel}
</Button>
</header>
<div className="mapped-list">
{items.map((item, index) => (
<article className="mapped-list-row" key={item.id}>
<div className="mapped-list-main min-w-0">
{item.image ? <img src={item.image} alt="" /> : <span className="image-placeholder" />}
<span className="min-w-0">
<b className="block truncate">{item.title}</b>
<small className="block truncate">
{"badge" in item ? `${item.category} · ${item.englishTitle}` : "tag" in item ? `${item.tag} · ${item.detailParagraphs?.length ?? 0} 段正文` : `${item.images?.length ?? 0} 张照片 · ${item.demandKeyword}`}
</small>
</span>
</div>
<div className="flex items-center gap-1">
<span className={`hidden rounded-full px-2.5 py-1 text-xs font-semibold sm:inline-flex ${item.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
{item.isActive ? "已启用" : "已停用"}
</span>
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`上移${item.title}`} disabled={index === 0 || moving} onClick={() => onMove(item.id, -1)}>
<ArrowUp size={15} />
</Button>
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`下移${item.title}`} disabled={index === items.length - 1 || moving} onClick={() => onMove(item.id, 1)}>
<ArrowDown size={15} />
</Button>
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`编辑${item.title}`} onClick={() => onEdit(item)}>
<Pencil size={15} />
</Button>
<Button type="button" variant="ghost" size="icon" className="module-rail-action danger" aria-label={`删除${item.title}`} onClick={() => onDelete(item)}>
<Trash2 size={15} />
</Button>
</div>
</article>
))}
{!items.length ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<p className="text-sm font-medium text-slate-700">暂无{meta.label}内容</p>
<p className="mt-1 text-xs leading-5 text-slate-500">点击右上角开始新增首页卡片。</p>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,162 @@
import { useState } from "react";
import { Save, X } from "lucide-react";
import type { HomeExperience, HomeTeamBuilding, HomeWildArchive } from "@/api";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { HomeContentSection } from "./HomeContentCardRail";
export type HomeContentDraft = {
badge: string;
category: string;
englishTitle: string;
tag: string;
title: string;
description: string;
image: string;
images: string[];
demandKeyword: string;
detailSubtitle: string;
detailParagraphs: string[];
isActive: boolean;
};
type HomeContentItem = HomeExperience | HomeTeamBuilding | HomeWildArchive;
function toDraft(item?: HomeContentItem): HomeContentDraft {
return {
badge: item && "badge" in item ? item.badge : "",
category: item && "category" in item ? item.category : "",
englishTitle: item && "englishTitle" in item ? item.englishTitle : "",
tag: item && "tag" in item ? item.tag : "",
title: item?.title ?? "",
description: item && "description" in item ? item.description : "",
image: item?.image ?? "",
images: item && "images" in item ? item.images ?? [] : [],
demandKeyword: item?.demandKeyword ?? "",
detailSubtitle: item && "detailSubtitle" in item ? item.detailSubtitle ?? "" : "",
detailParagraphs: item && "detailParagraphs" in item ? item.detailParagraphs ?? [] : [],
isActive: item?.isActive ?? true,
};
}
export function HomeContentEditor({
section,
mode,
initialItem,
saving,
onSave,
onClose,
}: {
section: HomeContentSection;
mode: "create" | "edit";
initialItem?: HomeContentItem;
saving: boolean;
onSave: (draft: HomeContentDraft) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(() => toDraft(initialItem));
const [error, setError] = useState("");
const update = (patch: Partial<HomeContentDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
if (error) setError("");
};
const sectionLabel = section === "experiences" ? "体验推荐" : section === "teamBuildings" ? "团队共创" : "极境视界";
const submit = () => {
const input = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, typeof value === "string" ? value.trim() : value])) as HomeContentDraft;
const required = section === "experiences"
? [input.badge, input.category, input.title, input.englishTitle, input.image, input.demandKeyword]
: section === "teamBuildings"
? [input.tag, input.title, input.description, input.image, input.demandKeyword, input.detailSubtitle, input.detailParagraphs.length ? "has-paragraphs" : ""]
: [input.title, input.image, input.demandKeyword, input.images.length ? "has-images" : ""];
if (required.some((value) => !value)) {
setError("请补充所有必填内容后再保存。" );
return;
}
onSave(input);
};
return (
<div className="mapped-editor flex h-full min-h-0 flex-col">
<header className="editor-head">
<span>
<h3>{mode === "create" ? "新增" : "编辑"}{sectionLabel}</h3>
<small>保存后会同步到首页 Public API 和 MiniAPP。</small>
</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} disabled={saving} aria-label="关闭首页内容编辑">
<X size={18} />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{section === "experiences" ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium text-slate-800">徽标<Input value={draft.badge} maxLength={40} onChange={(event) => update({ badge: event.target.value })} /></label>
<label className="grid gap-2 text-sm font-medium text-slate-800">分类<Input value={draft.category} maxLength={80} onChange={(event) => update({ category: event.target.value })} /></label>
<label className="grid gap-2 text-sm font-medium text-slate-800">标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
<label className="grid gap-2 text-sm font-medium text-slate-800">英文标题<Input value={draft.englishTitle} maxLength={120} onChange={(event) => update({ englishTitle: event.target.value })} /></label>
</div>
) : null}
{section === "teamBuildings" ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium text-slate-800">标签<Input value={draft.tag} maxLength={40} onChange={(event) => update({ tag: event.target.value })} /></label>
<label className="grid gap-2 text-sm font-medium text-slate-800">标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
</div>
) : null}
{section === "wildArchives" ? (
<label className="grid gap-2 text-sm font-medium text-slate-800">案例标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
) : null}
{section === "teamBuildings" ? (
<label className="mt-4 grid gap-2 text-sm font-medium text-slate-800">描述<Textarea value={draft.description} maxLength={240} rows={4} onChange={(event) => update({ description: event.target.value })} /></label>
) : null}
{section === "teamBuildings" ? (
<div className="mt-4 grid gap-4">
<label className="grid gap-2 text-sm font-medium text-slate-800">
详情副标题
<Input value={draft.detailSubtitle} maxLength={160} placeholder="详情页首屏副标题" onChange={(event) => update({ detailSubtitle: event.target.value })} />
</label>
<label className="grid gap-2 text-sm font-medium text-slate-800">
详情正文
<Textarea
value={draft.detailParagraphs.join("\n\n")}
rows={10}
placeholder="每段之间空一行,保存后按段落展示"
onChange={(event) => update({ detailParagraphs: event.target.value.split(/\r?\n\s*\r?\n/).map((value) => value.trim()).filter(Boolean) })}
/>
<small className="text-xs font-normal text-slate-500">空行分隔多个段落,当前 {draft.detailParagraphs.length} 段。</small>
</label>
</div>
) : null}
<label className="mt-4 grid gap-2 text-sm font-medium text-slate-800">需求关键词<Input value={draft.demandKeyword} maxLength={120} placeholder="点击卡片后预填到需求页" onChange={(event) => update({ demandKeyword: event.target.value })} /></label>
<div className="mt-5">
<p className="mb-2 text-sm font-medium text-slate-800">封面图片</p>
<SingleImageUploader value={draft.image} onChange={(value) => update({ image: value ?? "" })} group="home" />
</div>
{section === "wildArchives" ? (
<label className="mt-5 grid gap-2 text-sm font-medium text-slate-800">
详情图片
<Textarea
value={draft.images.join("\n")}
rows={6}
placeholder="每行填写一张图片 URL,至少一张"
onChange={(event) => update({ images: event.target.value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean) })}
/>
<small className="text-xs font-normal text-slate-500">第一张建议与封面一致;接口只保存 URL,不上传图片二进制。</small>
</label>
) : null}
<label className="mt-5 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-white p-4">
<span><b className="block text-sm font-semibold text-slate-900">前台启用</b><small className="mt-1 block text-xs text-slate-500">停用后保留在后台,不会进入 Public API。</small></span>
<Switch checked={draft.isActive} onCheckedChange={(checked) => update({ isActive: checked })} />
</label>
{error ? <p className="mt-3 text-sm text-rose-600">{error}</p> : null}
</div>
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
<Button type="button" onClick={submit} disabled={saving}><Save size={16} />{saving ? "保存中…" : mode === "create" ? "创建内容" : "保存修改"}</Button>
</footer>
</div>
);
}
@@ -0,0 +1,47 @@
import type { HomeContentSection } from "@/components/admin/HomeContentCardRail";
import type { ModuleId } from "@/types/admin";
export type HomeModuleKey =
| `structure:${ModuleId}`
| `content:${HomeContentSection}`
| "home:playRecommendations";
const modules: { key: HomeModuleKey; label: string }[] = [
{ key: "structure:heroSlides", label: "顶部轮播" },
{ key: "home:playRecommendations", label: "玩法推荐" },
{ key: "structure:vehicleOptions", label: "万趣用车" },
{ key: "content:experiences", label: "体验推荐" },
{ key: "content:teamBuildings", label: "团队共创" },
{ key: "content:wildArchives", label: "极境视界" },
];
export function HomeModuleRail({
activeModule,
onSelect,
}: {
activeModule: HomeModuleKey;
onSelect: (module: HomeModuleKey) => void;
}) {
return (
<section className="module-rail">
<h3>首页模块</h3>
{modules.map((item, index) => {
const active = item.key === activeModule;
return (
<button
type="button"
className={`module-rail-item home-module-item${active ? " active" : ""}`}
key={item.key}
aria-current={active ? "page" : undefined}
onClick={() => onSelect(item.key)}
>
<em>{index + 1}</em>
<span className="min-w-0">
<b className="block">{item.label}</b>
</span>
</button>
);
})}
</section>
);
}
@@ -0,0 +1,109 @@
import { useState } from "react";
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 { Switch } from "@/components/ui/switch";
export function HomeWanfaRecommendationRail({
categories,
items,
onAdd,
onDelete,
onMove,
onToggle,
savingKey,
}: {
categories: WanfaCategory[];
items: HomeWanfaRecommendation[];
onAdd: (categoryId: string) => void;
onDelete: (item: HomeWanfaRecommendation) => void;
onMove: (itemId: string, direction: -1 | 1) => void;
onToggle: (item: HomeWanfaRecommendation, isActive: boolean) => void;
savingKey: string;
}) {
const [selectedCategoryId, setSelectedCategoryId] = useState("");
const linkedCategoryIds = new Set(items.map((item) => item.categoryId));
const availableCategories = categories.filter((category) => !linkedCategoryIds.has(category.id));
return (
<section className="item-rail">
<header className="module-data-head">
<span>
<h3>玩法推荐</h3>
<p>关联玩法分类,维护首页推荐入口和展示顺序。</p>
</span>
<div className="flex items-center gap-2">
<NativeSelect
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)}
>
<option value="">关联分类</option>
{availableCategories.map((category) => (
<option value={category.id} key={category.id}>
{category.label}
</option>
))}
</NativeSelect>
<Button
type="button"
className="primary-action compact"
disabled={!selectedCategoryId || savingKey === "add"}
onClick={() => {
onAdd(selectedCategoryId);
setSelectedCategoryId("");
}}
>
<Plus size={16} />
新增推荐
</Button>
</div>
</header>
<div className="mapped-list">
{items.map((item, index) => (
<article className="mapped-list-row" key={item.id}>
<div className="mapped-list-main min-w-0">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-teal-50 text-sm font-semibold text-teal-700">
{index + 1}
</span>
<span className="min-w-0">
<b className="block truncate">{item.categoryLabel}</b>
<small className="block truncate">玩法分类 · {item.routeCount} 条路线</small>
</span>
</div>
<div className="flex items-center gap-1">
<label className="mr-1 flex items-center gap-2 text-xs text-slate-600">
<Switch
checked={item.isActive}
aria-label={`${item.categoryLabel}${item.isActive ? "已启用" : "已停用"}`}
disabled={savingKey === item.id}
onCheckedChange={(checked) => onToggle(item, checked)}
/>
<span className="hidden sm:inline">{item.isActive ? "已启用" : "已停用"}</span>
</label>
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`上移${item.categoryLabel}`} disabled={index === 0 || savingKey === "reorder"} onClick={() => onMove(item.id, -1)}>
<ArrowUp size={15} />
</Button>
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`下移${item.categoryLabel}`} disabled={index === items.length - 1 || savingKey === "reorder"} onClick={() => onMove(item.id, 1)}>
<ArrowDown size={15} />
</Button>
<Button type="button" variant="ghost" size="icon" className="module-rail-action danger" aria-label={`移除${item.categoryLabel}`} disabled={savingKey === "delete"} onClick={() => onDelete(item)}>
<Trash2 size={15} />
</Button>
</div>
</article>
))}
{!items.length ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<p className="text-sm font-medium text-slate-700">暂无玩法推荐</p>
<p className="mt-1 text-xs leading-5 text-slate-500">从右上角选择玩法分类,添加到首页推荐。</p>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,86 @@
import { useState } from "react";
import { Save, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function WanfaCategoryEditor({
mode,
initialLabel,
saving,
onSave,
onClose,
}: {
mode: "create" | "edit";
initialLabel: string;
saving: boolean;
onSave: (label: string) => void;
onClose: () => void;
}) {
const [label, setLabel] = useState(initialLabel);
const [error, setError] = useState("");
const submit = () => {
const nextLabel = label.trim();
if (!nextLabel) {
setError("请填写分类名称。");
return;
}
setError("");
onSave(nextLabel);
};
return (
<div className="mapped-editor">
<header className="editor-head">
<span>
<h3>{mode === "create" ? "添加玩法分类" : "编辑玩法分类"}</h3>
<small>{mode === "create" ? "创建后即可在分类下维护路线。" : "修改后会同步更新前台分类名称。"}</small>
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="关闭分类编辑"
disabled={saving}
>
<X size={18} />
</Button>
</header>
<div className="min-h-0 overflow-y-auto px-5 py-5">
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-category-label">
分类名称
<Input
id="wanfa-category-label"
autoFocus
value={label}
maxLength={40}
placeholder="例如:亲子路线"
onChange={(event) => {
setLabel(event.target.value);
if (error) setError("");
}}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
</label>
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
<p className="mt-3 text-xs leading-5 text-slate-500">
分类名称用于后台列表和前台玩法入口展示,保存前会自动去除首尾空格。
</p>
</div>
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
取消
</Button>
<Button type="button" onClick={submit} disabled={saving}>
<Save size={16} />
{saving ? "保存中…" : mode === "create" ? "创建分类" : "保存修改"}
</Button>
</footer>
</div>
);
}
@@ -0,0 +1,109 @@
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import type { WanfaCategory } from "@/api";
import { Button } from "@/components/ui/button";
export function WanfaCategoryRail({
categories,
selectedCategoryId,
onSelect,
onCreate,
onEdit,
onDelete,
onMove,
movingId,
}: {
categories: WanfaCategory[];
selectedCategoryId: string;
onSelect: (categoryId: string) => void;
onCreate: () => void;
onEdit: (category: WanfaCategory) => void;
onDelete: (category: WanfaCategory) => void;
onMove: (categoryId: string, direction: -1 | 1) => void;
movingId?: string;
}) {
return (
<section className="module-rail">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="mb-0">玩法分类</h3>
<Button type="button" className="primary-action compact" aria-label="添加玩法分类" onClick={onCreate}>
<Plus size={15} />
添加分类
</Button>
</div>
{categories.map((category, index) => {
const isSelected = category.id === selectedCategoryId;
return (
<div className={`module-rail-item${isSelected ? " active" : ""}`} key={category.id}>
<button
type="button"
className="module-rail-select"
aria-pressed={isSelected}
onClick={() => onSelect(category.id)}
>
<em>{index + 1}</em>
<span>
<b>{category.label}</b>
<small>{category.routes.length ? `${category.routes.length} 条路线` : "暂无路线"}</small>
</span>
</button>
<div className="module-rail-actions">
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`上移${category.label}`}
title={`上移${category.label}`}
disabled={index === 0 || Boolean(movingId)}
onClick={() => onMove(category.id, -1)}
>
<ArrowUp size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`下移${category.label}`}
title={`下移${category.label}`}
disabled={index === categories.length - 1 || Boolean(movingId)}
onClick={() => onMove(category.id, 1)}
>
<ArrowDown size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`编辑${category.label}`}
title={`编辑${category.label}`}
onClick={() => onEdit(category)}
>
<Pencil size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action danger"
aria-label={`删除${category.label}`}
title={`删除${category.label}`}
onClick={() => onDelete(category)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
);
})}
{!categories.length ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-5 text-center">
<p className="text-sm font-medium text-slate-700">还没有玩法分类</p>
<p className="mt-1 text-xs leading-5 text-slate-500">添加一个分类,再维护分类下的路线。</p>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,90 @@
import { useEffect, useRef } from "react";
import { AlertTriangle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
export function WanfaConfirmDialog({
title,
description,
confirmLabel = "确认删除",
confirmDisabled = false,
pending = false,
onConfirm,
onClose,
}: {
title: string;
description: string;
confirmLabel?: string;
confirmDisabled?: boolean;
pending?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
closeButtonRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !pending) onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, pending]);
return (
<div className="fixed inset-0 z-[80] grid place-items-center bg-slate-950/30 p-4 backdrop-blur-[2px]">
<button
type="button"
className="absolute inset-0 h-full w-full cursor-default"
aria-label="关闭确认弹窗"
onClick={() => !pending && onClose()}
/>
<section
className="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
role="alertdialog"
aria-modal="true"
aria-labelledby="wanfa-confirm-title"
>
<div className="flex items-start gap-4">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-600">
<AlertTriangle size={20} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<h2 id="wanfa-confirm-title" className="text-base font-semibold text-slate-950">
{title}
</h2>
<Button
ref={closeButtonRef}
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="取消操作"
onClick={onClose}
disabled={pending}
>
<X size={17} />
</Button>
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">{description}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={pending || confirmDisabled}
>
{pending ? "删除中…" : confirmLabel}
</Button>
</div>
</section>
</div>
);
}
@@ -0,0 +1,161 @@
import { useState } from "react";
import { Save, X } from "lucide-react";
import type { WanfaRoute, WanfaRouteCreate } from "@/api";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
const emptyRoute = (): WanfaRouteCreate => ({
title: "",
subtitle: "",
image: "",
routeCount: 0,
demandKeyword: "",
});
const routeDraft = (route?: WanfaRoute): WanfaRouteCreate =>
route
? {
title: route.title,
subtitle: route.subtitle,
image: route.image,
routeCount: route.routeCount,
demandKeyword: route.demandKeyword,
}
: emptyRoute();
export function WanfaRouteEditor({
mode,
categoryLabel,
initialRoute,
saving,
onSave,
onClose,
}: {
mode: "create" | "edit";
categoryLabel: string;
initialRoute?: WanfaRoute;
saving: boolean;
onSave: (input: WanfaRouteCreate) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState<WanfaRouteCreate>(() => routeDraft(initialRoute));
const [error, setError] = useState("");
const update = <K extends keyof WanfaRouteCreate>(key: K, value: WanfaRouteCreate[K]) => {
setDraft((current) => ({ ...current, [key]: value }));
if (error) setError("");
};
const submit = () => {
const nextDraft = {
...draft,
title: draft.title.trim(),
subtitle: draft.subtitle.trim(),
image: draft.image.trim(),
demandKeyword: draft.demandKeyword.trim(),
};
if (!nextDraft.title || !nextDraft.subtitle || !nextDraft.image || !nextDraft.demandKeyword) {
setError("请完整填写标题、副标题、封面和需求关键词。");
return;
}
if (!Number.isInteger(nextDraft.routeCount) || nextDraft.routeCount < 0) {
setError("路线数量必须是大于等于 0 的整数。");
return;
}
setError("");
onSave(nextDraft);
};
return (
<div className="mapped-editor">
<header className="editor-head">
<span>
<h3>{mode === "create" ? "新增分类路线" : "编辑分类路线"}</h3>
<small>
{mode === "create"
? `添加到「${categoryLabel}」,保存后会排在当前路线末尾。`
: `修改「${categoryLabel}」下的路线卡片内容。`}
</small>
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="关闭路线编辑"
disabled={saving}
>
<X size={18} />
</Button>
</header>
<div className="min-h-0 space-y-5 overflow-y-auto px-5 py-5">
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-route-title">
路线标题
<Input
id="wanfa-route-title"
autoFocus
value={draft.title}
maxLength={80}
placeholder="例如:亲子玩水"
onChange={(event) => update("title", event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-route-subtitle">
副标题/目的地说明
<Textarea
id="wanfa-route-subtitle"
value={draft.subtitle}
maxLength={160}
placeholder="例如:贵州·轻松节奏与自然课堂"
rows={3}
onChange={(event) => update("subtitle", event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-route-count">
路线数量
<Input
id="wanfa-route-count"
type="number"
min={0}
step={1}
value={draft.routeCount}
onChange={(event) => update("routeCount", Number(event.target.value))}
/>
</label>
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-route-keyword">
需求关键词
<Input
id="wanfa-route-keyword"
value={draft.demandKeyword}
maxLength={60}
placeholder="例如:亲子玩水"
onChange={(event) => update("demandKeyword", event.target.value)}
/>
</label>
</div>
<div className="grid gap-2 text-sm font-medium text-slate-800">
<span>路线封面</span>
<SingleImageUploader
value={draft.image}
onChange={(image) => update("image", image ?? "")}
group="wanfa"
/>
</div>
{error ? <p className="text-sm leading-5 text-rose-600">{error}</p> : null}
</div>
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
取消
</Button>
<Button type="button" onClick={submit} disabled={saving}>
<Save size={16} />
{saving ? (mode === "create" ? "创建中…" : "保存中…") : mode === "create" ? "创建路线" : "保存修改"}
</Button>
</footer>
</div>
);
}
@@ -0,0 +1,112 @@
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import type { WanfaCategory, WanfaRoute } from "@/api";
import { EmptyState } from "@/components/admin/EmptyState";
import { Button } from "@/components/ui/button";
export function WanfaRouteRail({
category,
onCreate,
onEdit,
onDelete,
onMove,
movingId,
}: {
category?: WanfaCategory;
onCreate: () => void;
onEdit: (route: WanfaRoute) => void;
onDelete: (route: WanfaRoute) => void;
onMove: (routeId: string, direction: -1 | 1) => void;
movingId?: string;
}) {
return (
<section className="item-rail">
<header className="module-data-head">
<span>
<h3>{category?.label ?? "玩法路线"}</h3>
<p>维护分类下的路线卡片,新增后会在前台玩法入口中展示。</p>
</span>
{category ? (
<Button type="button" className="primary-action compact" onClick={onCreate}>
<Plus size={16} />
新增路线
</Button>
) : null}
</header>
{category ? (
<div className="mapped-list">
{category.routes.map((route, index) => (
<div className="mapped-list-row" key={route.id}>
<div className="mapped-list-main">
{route.image ? <img src={route.image} alt="" /> : <span className="image-placeholder" />}
<span>
<b>{route.title}</b>
<small>{route.subtitle} · 路线 {route.routeCount} 条 · 需求:{route.demandKeyword}</small>
</span>
</div>
<div className="flex items-center gap-1">
<span className="hidden rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
已配置
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`上移${route.title}`}
title={`上移${route.title}`}
disabled={index === 0 || Boolean(movingId)}
onClick={() => onMove(route.id, -1)}
>
<ArrowUp size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`下移${route.title}`}
title={`下移${route.title}`}
disabled={index === category.routes.length - 1 || Boolean(movingId)}
onClick={() => onMove(route.id, 1)}
>
<ArrowDown size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action"
aria-label={`编辑${route.title}`}
title={`编辑${route.title}`}
onClick={() => onEdit(route)}
>
<Pencil size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="module-rail-action danger"
aria-label={`删除${route.title}`}
title={`删除${route.title}`}
onClick={() => onDelete(route)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
))}
{!category.routes.length ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<p className="text-sm font-medium text-slate-700">暂无路线</p>
<p className="mt-1 text-xs leading-5 text-slate-500">点击右上角“新增路线”开始维护「{category.label}」。</p>
</div>
) : null}
</div>
) : (
<EmptyState text="请先添加或选择一个玩法分类。" />
)}
</section>
);
}