feat(route-detail): 实现路线详情独立管理全链路功能

添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。
在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。
完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。
更新所有相关文档,明确领域边界、联调流程及接口契约规范。
调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。
新增数值格式化工具函数优化内容展示效果。
This commit is contained in:
duanshuwen committed 2026-08-19 21:20:51 +08:00
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>