feat(route-detail): 实现路线详情独立管理全链路功能
添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。 在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。 完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。 更新所有相关文档,明确领域边界、联调流程及接口契约规范。 调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。 新增数值格式化工具函数优化内容展示效果。
This commit is contained in:
1 parent
7ba92f3c5d
commit
c143b67275
32 files changed
+1343
-259
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import type { DetailCreate } from "@/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const detailArrayFields = [
|
||||
["highlights", "行程亮点", "每行填写一项亮点"],
|
||||
["included", "费用包含", "每行填写一项包含内容"],
|
||||
["excluded", "费用不含", "每行填写一项不包含内容"],
|
||||
["notes", "注意事项", "每行填写一项注意事项"],
|
||||
["gallery", "详情图片画廊", "每行填写一个 http(s) 图片地址"],
|
||||
] as const;
|
||||
|
||||
type DetailArrayField = (typeof detailArrayFields)[number][0];
|
||||
|
||||
export function WanfaRouteDetailFields({
|
||||
detail,
|
||||
onChange,
|
||||
}: {
|
||||
detail: DetailCreate;
|
||||
onChange: <K extends keyof DetailCreate>(key: K, value: DetailCreate[K]) => void;
|
||||
}) {
|
||||
const updateLines = (key: DetailArrayField, value: string) => {
|
||||
onChange(
|
||||
key,
|
||||
value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 border-t border-slate-100 pt-5">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-slate-900">路线详情</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
详情内容由独立详情接口维护,数组字段按每行一项保存。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-detail-eyebrow">
|
||||
详情眉标
|
||||
<Input
|
||||
id="wanfa-detail-eyebrow"
|
||||
value={detail.eyebrow}
|
||||
maxLength={40}
|
||||
placeholder="例如:亲子路线"
|
||||
onChange={(event) => onChange("eyebrow", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-detail-duration">
|
||||
出行时长
|
||||
<Input
|
||||
id="wanfa-detail-duration"
|
||||
value={detail.duration}
|
||||
maxLength={40}
|
||||
placeholder="例如:5天4晚"
|
||||
onChange={(event) => onChange("duration", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-detail-title">
|
||||
详情标题
|
||||
<Input
|
||||
id="wanfa-detail-title"
|
||||
value={detail.title}
|
||||
maxLength={120}
|
||||
placeholder="例如:贵州亲子玩水路线"
|
||||
onChange={(event) => onChange("title", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-detail-subtitle">
|
||||
详情副标题
|
||||
<Textarea
|
||||
id="wanfa-detail-subtitle"
|
||||
value={detail.subtitle}
|
||||
maxLength={240}
|
||||
rows={2}
|
||||
placeholder="用于详情页标题下方的补充说明"
|
||||
onChange={(event) => onChange("subtitle", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-detail-intro">
|
||||
详情介绍
|
||||
<Textarea
|
||||
id="wanfa-detail-intro"
|
||||
value={detail.intro}
|
||||
maxLength={1000}
|
||||
rows={5}
|
||||
placeholder="介绍路线的整体体验和适合人群"
|
||||
onChange={(event) => onChange("intro", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{detailArrayFields.map(([key, label, placeholder]) => (
|
||||
<label
|
||||
className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2"
|
||||
htmlFor={`wanfa-detail-${key}`}
|
||||
key={key}
|
||||
>
|
||||
{label}
|
||||
<Textarea
|
||||
id={`wanfa-detail-${key}`}
|
||||
value={detail[key].join("\n")}
|
||||
rows={key === "gallery" ? 3 : 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)}
|
||||
/>
|
||||
启用路线详情
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import type { WanfaRoute, WanfaRouteCreate } from "@/api";
|
||||
import type { DetailCreate, DetailRecord, WanfaRoute, WanfaRouteCreate } from "@/api";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { WanfaRouteDetailFields } from "@/components/admin/WanfaRouteDetailFields";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -15,6 +16,21 @@ const emptyRoute = (): WanfaRouteCreate => ({
|
||||
demandKeyword: "",
|
||||
});
|
||||
|
||||
const emptyDetail = (): DetailCreate => ({
|
||||
key: "",
|
||||
eyebrow: "玩法路线",
|
||||
duration: "",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
intro: "",
|
||||
highlights: [],
|
||||
included: [],
|
||||
excluded: [],
|
||||
notes: [],
|
||||
gallery: [],
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const routeDraft = (route?: WanfaRoute): WanfaRouteCreate =>
|
||||
route
|
||||
? {
|
||||
@@ -26,10 +42,35 @@ const routeDraft = (route?: WanfaRoute): WanfaRouteCreate =>
|
||||
}
|
||||
: emptyRoute();
|
||||
|
||||
const detailDraft = (detail?: DetailRecord): DetailCreate =>
|
||||
detail
|
||||
? {
|
||||
key: detail.key,
|
||||
eyebrow: detail.eyebrow,
|
||||
duration: detail.duration,
|
||||
title: detail.title,
|
||||
subtitle: detail.subtitle,
|
||||
intro: detail.intro,
|
||||
highlights: detail.highlights ?? [],
|
||||
included: detail.included ?? [],
|
||||
excluded: detail.excluded ?? [],
|
||||
notes: detail.notes ?? [],
|
||||
gallery: detail.gallery ?? [],
|
||||
isActive: detail.isActive,
|
||||
sortOrder: detail.sortOrder,
|
||||
}
|
||||
: emptyDetail();
|
||||
|
||||
export type WanfaRouteEditorValue = {
|
||||
route: WanfaRouteCreate;
|
||||
detail: DetailCreate;
|
||||
};
|
||||
|
||||
export function WanfaRouteEditor({
|
||||
mode,
|
||||
categoryLabel,
|
||||
initialRoute,
|
||||
initialDetail,
|
||||
saving,
|
||||
onSave,
|
||||
onClose,
|
||||
@@ -37,34 +78,69 @@ export function WanfaRouteEditor({
|
||||
mode: "create" | "edit";
|
||||
categoryLabel: string;
|
||||
initialRoute?: WanfaRoute;
|
||||
initialDetail?: DetailRecord;
|
||||
saving: boolean;
|
||||
onSave: (input: WanfaRouteCreate) => void;
|
||||
onSave: (input: WanfaRouteEditorValue) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<WanfaRouteCreate>(() => routeDraft(initialRoute));
|
||||
const [draft, setDraft] = useState<WanfaRouteEditorValue>(() => ({
|
||||
route: routeDraft(initialRoute),
|
||||
detail: detailDraft(initialDetail),
|
||||
}));
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const update = <K extends keyof WanfaRouteCreate>(key: K, value: WanfaRouteCreate[K]) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setDraft((current) => ({ ...current, route: { ...current.route, [key]: value } }));
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const updateDetail = <K extends keyof DetailCreate>(key: K, value: DetailCreate[K]) => {
|
||||
setDraft((current) => ({ ...current, detail: { ...current.detail, [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(),
|
||||
route: {
|
||||
...draft.route,
|
||||
title: draft.route.title.trim(),
|
||||
subtitle: draft.route.subtitle.trim(),
|
||||
image: draft.route.image.trim(),
|
||||
demandKeyword: draft.route.demandKeyword.trim(),
|
||||
},
|
||||
detail: {
|
||||
...draft.detail,
|
||||
key: draft.detail.key.trim(),
|
||||
eyebrow: draft.detail.eyebrow.trim(),
|
||||
duration: draft.detail.duration.trim(),
|
||||
title: draft.detail.title.trim(),
|
||||
subtitle: draft.detail.subtitle.trim(),
|
||||
intro: draft.detail.intro.trim(),
|
||||
},
|
||||
};
|
||||
if (!nextDraft.title || !nextDraft.subtitle || !nextDraft.image || !nextDraft.demandKeyword) {
|
||||
if (
|
||||
!nextDraft.route.title ||
|
||||
!nextDraft.route.subtitle ||
|
||||
!nextDraft.route.image ||
|
||||
!nextDraft.route.demandKeyword
|
||||
) {
|
||||
setError("请完整填写标题、副标题、封面和需求关键词。");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(nextDraft.routeCount) || nextDraft.routeCount < 0) {
|
||||
if (!Number.isInteger(nextDraft.route.routeCount) || nextDraft.route.routeCount < 0) {
|
||||
setError("路线数量必须是大于等于 0 的整数。");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!nextDraft.detail.eyebrow ||
|
||||
!nextDraft.detail.duration ||
|
||||
!nextDraft.detail.title ||
|
||||
!nextDraft.detail.subtitle ||
|
||||
!nextDraft.detail.intro
|
||||
) {
|
||||
setError("请完整填写详情眉标、时长、标题、副标题和详情介绍。");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSave(nextDraft);
|
||||
};
|
||||
@@ -98,7 +174,7 @@ export function WanfaRouteEditor({
|
||||
<Input
|
||||
id="wanfa-route-title"
|
||||
autoFocus
|
||||
value={draft.title}
|
||||
value={draft.route.title}
|
||||
maxLength={80}
|
||||
placeholder="例如:亲子玩水"
|
||||
onChange={(event) => update("title", event.target.value)}
|
||||
@@ -108,7 +184,7 @@ export function WanfaRouteEditor({
|
||||
副标题/目的地说明
|
||||
<Textarea
|
||||
id="wanfa-route-subtitle"
|
||||
value={draft.subtitle}
|
||||
value={draft.route.subtitle}
|
||||
maxLength={160}
|
||||
placeholder="例如:贵州·轻松节奏与自然课堂"
|
||||
rows={3}
|
||||
@@ -122,7 +198,7 @@ export function WanfaRouteEditor({
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={draft.routeCount}
|
||||
value={draft.route.routeCount}
|
||||
onChange={(event) => update("routeCount", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
@@ -130,7 +206,7 @@ export function WanfaRouteEditor({
|
||||
需求关键词
|
||||
<Input
|
||||
id="wanfa-route-keyword"
|
||||
value={draft.demandKeyword}
|
||||
value={draft.route.demandKeyword}
|
||||
maxLength={60}
|
||||
placeholder="例如:亲子玩水"
|
||||
onChange={(event) => update("demandKeyword", event.target.value)}
|
||||
@@ -140,11 +216,12 @@ export function WanfaRouteEditor({
|
||||
<div className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
<span>路线封面</span>
|
||||
<SingleImageUploader
|
||||
value={draft.image}
|
||||
value={draft.route.image}
|
||||
onChange={(image) => update("image", image ?? "")}
|
||||
group="wanfa"
|
||||
/>
|
||||
</div>
|
||||
<WanfaRouteDetailFields detail={draft.detail} onChange={updateDetail} />
|
||||
{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">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { WanfaCategory, WanfaRoute } from "@/api";
|
||||
import type { DetailRecord, WanfaCategory, WanfaRoute } from "@/api";
|
||||
import { EmptyState } from "@/components/admin/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function WanfaRouteRail({
|
||||
category,
|
||||
details,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -13,6 +14,7 @@ export function WanfaRouteRail({
|
||||
movingId,
|
||||
}: {
|
||||
category?: WanfaCategory;
|
||||
details: DetailRecord[];
|
||||
onCreate: () => void;
|
||||
onEdit: (route: WanfaRoute) => void;
|
||||
onDelete: (route: WanfaRoute) => void;
|
||||
@@ -35,18 +37,22 @@ export function WanfaRouteRail({
|
||||
</header>
|
||||
{category ? (
|
||||
<div className="mapped-list">
|
||||
{category.routes.map((route, index) => (
|
||||
<div className="mapped-list-row" key={route.id}>
|
||||
<div className="mapped-list-main">
|
||||
{category.routes.map((route, index) => {
|
||||
const hasDetail = details.some((detail) => detail.key === route.id);
|
||||
return (
|
||||
<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">
|
||||
已配置
|
||||
</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 ${hasDetail ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}
|
||||
>
|
||||
{hasDetail ? "详情已配置" : "详情未配置"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -94,9 +100,10 @@ export function WanfaRouteRail({
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user