Compare commits
9
Commits
d5a92e1dae
...
duohua-new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37eaa6e4c | ||
|
|
44b1056ba7 | ||
|
|
163f433970 | ||
|
|
07a7a0f516 | ||
|
|
9ed03459c2 | ||
|
|
a04d7e267d | ||
|
|
6ec590b324 | ||
|
|
b36f2864bb | ||
|
|
951bdf523f |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appid": "wx5e79df5996572539",
|
||||
"appid": "wx23f86d809ae80259",
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.8.10",
|
||||
"packOptions": {
|
||||
|
||||
@@ -12,7 +12,7 @@ import rawConfigs from "../../client-configs.json" with { type: "json" };
|
||||
export const CLIENT_CONFIGS = rawConfigs;
|
||||
|
||||
// 获取当前用户端配置
|
||||
export const getCurrentConfig = () => CLIENT_CONFIGS.zhinian;
|
||||
export const getCurrentConfig = () => CLIENT_CONFIGS.duohua;
|
||||
export const clientId = getCurrentConfig().clientId;
|
||||
export const appId = getCurrentConfig().appId;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getCurrentConfig, currentClientType, ClientType } from "@/constant/base
|
||||
|
||||
export const chatInputPlaceholder = () => {
|
||||
const config = getCurrentConfig();
|
||||
return `快告诉${config.name}您在想什么~`;
|
||||
return "龙里有什么必吃美食?";// `快告诉${config.name}您在想什么~`;
|
||||
}
|
||||
|
||||
export const chatGuideSubTitle = () => {
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx5e79df5996572539",
|
||||
"appid" : "wx23f86d809ae80259",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
<template>
|
||||
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||
<scroll-view class="min-h-0 flex-1" scroll-y>
|
||||
<view class="relative flex h-full min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||
<view
|
||||
v-if="isTabsPinned && detailSections.length"
|
||||
class="absolute left-0 right-0 top-0 z-30 border-b border-slate-100 bg-white shadow-[0_4px_12px_rgba(15,23,42,0.08)]"
|
||||
>
|
||||
<TicketDetailTabs
|
||||
:sections="detailSections"
|
||||
:active-section-code="activeSectionCode"
|
||||
instance-key="pinned"
|
||||
@select="selectDetailSection"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
id="ticket-detail-scroll"
|
||||
class="min-h-0 flex-1"
|
||||
scroll-y
|
||||
:scroll-top="requestedScrollTop"
|
||||
scroll-with-animation
|
||||
@scroll="handleDetailScroll"
|
||||
@touchstart="enableDetailScrollSync"
|
||||
>
|
||||
<view v-if="productImages.length" class="relative h-56 overflow-hidden bg-white">
|
||||
<TicketMediaSwiper
|
||||
:media-list="productImages"
|
||||
@@ -29,13 +49,36 @@
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="section in detailSections"
|
||||
v-if="detailSections.length"
|
||||
id="ticket-detail-tabs-anchor"
|
||||
class="mt-[12px] border-y border-slate-100 bg-white"
|
||||
>
|
||||
<TicketDetailTabs
|
||||
:sections="detailSections"
|
||||
:active-section-code="activeSectionCode"
|
||||
instance-key="inline"
|
||||
@select="selectDetailSection"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="(section, index) in detailSections"
|
||||
:id="`ticket-detail-section-${index}`"
|
||||
:key="section.sectionCode"
|
||||
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
|
||||
:class="section === detailSections[detailSections.length - 1] ? 'mb-[20px]' : ''"
|
||||
>
|
||||
<text class="block text-[16px] font-semibold text-slate-800">{{ section.title }}</text>
|
||||
<text class="mt-[10px] block whitespace-pre-wrap text-[14px] leading-[22px] text-slate-600">{{ section.content }}</text>
|
||||
<view class="mt-[10px] text-[14px] leading-[22px] text-slate-600">
|
||||
<MpHtml
|
||||
:content="section.content || ''"
|
||||
:preview-img="false"
|
||||
:show-img-menu="false"
|
||||
:set-title="false"
|
||||
:tag-style="detailContentTagStyle"
|
||||
@ready="measureDetailSectionOffsets"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -54,14 +97,52 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import MpHtml from "@/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue";
|
||||
import TicketDetailTabs from "./TicketDetailTabs.vue";
|
||||
import TicketMediaSwiper from "./TicketMediaSwiper.vue";
|
||||
|
||||
const createDetailSectionScrollSelection = () => {
|
||||
let shouldSyncFromScroll = true;
|
||||
|
||||
return {
|
||||
select(sectionCode) {
|
||||
shouldSyncFromScroll = false;
|
||||
return String(sectionCode ?? "");
|
||||
},
|
||||
resumeScrollSync() {
|
||||
shouldSyncFromScroll = true;
|
||||
},
|
||||
resolve(currentSectionCode, scrollSectionCode) {
|
||||
return shouldSyncFromScroll
|
||||
? String(scrollSectionCode ?? "")
|
||||
: String(currentSectionCode ?? "");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["buy"]);
|
||||
const detailContentTagStyle = {
|
||||
p: "margin: 8px 0; line-height: 1.7; color: #475569; word-break: break-word;",
|
||||
img: "max-width: 100%; height: auto; display: block; margin: 10px auto;",
|
||||
ul: "margin: 8px 0; padding-left: 20px; color: #475569;",
|
||||
ol: "margin: 8px 0; padding-left: 20px; color: #475569;",
|
||||
li: "margin: 4px 0; line-height: 1.7; color: #475569;",
|
||||
blockquote:
|
||||
"margin: 10px 0; padding: 8px 12px; border-left: 3px solid #cbd5e1; background-color: #f8fafc; color: #64748b;",
|
||||
strong: "font-weight: 700; color: #334155;",
|
||||
};
|
||||
const productImages = computed(() => [
|
||||
...new Set(
|
||||
[
|
||||
@@ -77,6 +158,181 @@ const detailSections = computed(() =>
|
||||
(left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)
|
||||
)
|
||||
);
|
||||
const activeSectionCode = ref(
|
||||
String(detailSections.value[0]?.sectionCode ?? "")
|
||||
);
|
||||
const requestedScrollTop = ref(0);
|
||||
const currentDetailScrollTop = ref(0);
|
||||
const detailScrollHeight = ref(0);
|
||||
const detailScrollViewportHeight = ref(0);
|
||||
const tabsOffsetTop = ref(Number.POSITIVE_INFINITY);
|
||||
const tabsHeight = ref(48);
|
||||
const detailSectionOffsets = ref([]);
|
||||
const isTabsPinned = ref(false);
|
||||
const pendingSectionCode = ref("");
|
||||
const detailSectionScrollSelection =
|
||||
createDetailSectionScrollSelection();
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const scrollToMeasuredDetailSection = (sectionCode) => {
|
||||
const normalizedSectionCode = String(sectionCode ?? "");
|
||||
const sectionOffset = detailSectionOffsets.value.find(
|
||||
(section) => section.code === normalizedSectionCode
|
||||
);
|
||||
if (!sectionOffset) return false;
|
||||
|
||||
const targetScrollTop = Math.max(
|
||||
0,
|
||||
sectionOffset.top - tabsHeight.value
|
||||
);
|
||||
requestedScrollTop.value = currentDetailScrollTop.value;
|
||||
nextTick(() => {
|
||||
requestedScrollTop.value = targetScrollTop;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const selectDetailSection = (sectionCode) => {
|
||||
activeSectionCode.value = detailSectionScrollSelection.select(sectionCode);
|
||||
if (scrollToMeasuredDetailSection(activeSectionCode.value)) return;
|
||||
|
||||
pendingSectionCode.value = activeSectionCode.value;
|
||||
measureDetailSectionOffsets();
|
||||
};
|
||||
|
||||
const enableDetailScrollSync = () => {
|
||||
detailSectionScrollSelection.resumeScrollSync();
|
||||
};
|
||||
|
||||
const resolveScrollSectionCode = (scrollTop, scrollHeight) => {
|
||||
if (!detailSectionOffsets.value.length) return "";
|
||||
|
||||
const isAtBottom =
|
||||
scrollHeight > 0 &&
|
||||
detailScrollViewportHeight.value > 0 &&
|
||||
scrollTop + detailScrollViewportHeight.value >=
|
||||
scrollHeight - 4;
|
||||
if (isAtBottom) {
|
||||
return detailSectionOffsets.value[detailSectionOffsets.value.length - 1]
|
||||
.code;
|
||||
}
|
||||
|
||||
const activationLine =
|
||||
scrollTop + (isTabsPinned.value ? tabsHeight.value + 8 : 8);
|
||||
return detailSectionOffsets.value.reduce(
|
||||
(current, section) =>
|
||||
section.top <= activationLine ? section : current,
|
||||
detailSectionOffsets.value[0]
|
||||
).code;
|
||||
};
|
||||
|
||||
const syncActiveDetailSection = (scrollTop, scrollHeight) => {
|
||||
const scrollSectionCode = resolveScrollSectionCode(
|
||||
scrollTop,
|
||||
scrollHeight
|
||||
);
|
||||
if (!scrollSectionCode) return;
|
||||
|
||||
activeSectionCode.value = detailSectionScrollSelection.resolve(
|
||||
activeSectionCode.value,
|
||||
scrollSectionCode
|
||||
);
|
||||
};
|
||||
|
||||
const measureDetailSectionOffsets = () => {
|
||||
if (!detailSections.value.length) {
|
||||
tabsOffsetTop.value = Number.POSITIVE_INFINITY;
|
||||
detailSectionOffsets.value = [];
|
||||
isTabsPinned.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||
query.select("#ticket-detail-scroll").boundingClientRect();
|
||||
query.select("#ticket-detail-tabs-anchor").boundingClientRect();
|
||||
detailSections.value.forEach((section, index) => {
|
||||
query
|
||||
.select(`#ticket-detail-section-${index}`)
|
||||
.boundingClientRect();
|
||||
});
|
||||
query.exec((rects = []) => {
|
||||
const scrollRect = rects[0];
|
||||
const tabsRect = rects[1];
|
||||
if (!scrollRect || !tabsRect) return;
|
||||
|
||||
const scrollTop = currentDetailScrollTop.value;
|
||||
detailScrollViewportHeight.value = Number(scrollRect.height || 0);
|
||||
tabsOffsetTop.value = Math.max(
|
||||
0,
|
||||
Number(tabsRect.top || 0) - Number(scrollRect.top || 0) + scrollTop
|
||||
);
|
||||
tabsHeight.value = Number(tabsRect.height || 48);
|
||||
detailSectionOffsets.value = detailSections.value
|
||||
.map((section, index) => {
|
||||
const sectionRect = rects[index + 2];
|
||||
if (!sectionRect) return null;
|
||||
return {
|
||||
code: String(section.sectionCode ?? ""),
|
||||
top: Math.max(
|
||||
0,
|
||||
Number(sectionRect.top || 0) -
|
||||
Number(scrollRect.top || 0) +
|
||||
scrollTop
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
isTabsPinned.value = scrollTop >= tabsOffsetTop.value;
|
||||
syncActiveDetailSection(scrollTop, detailScrollHeight.value);
|
||||
|
||||
if (pendingSectionCode.value) {
|
||||
const sectionCode = pendingSectionCode.value;
|
||||
pendingSectionCode.value = "";
|
||||
scrollToMeasuredDetailSection(sectionCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDetailScroll = (event) => {
|
||||
const scrollTop = Number(event.detail?.scrollTop || 0);
|
||||
const scrollHeight = Number(event.detail?.scrollHeight || 0);
|
||||
currentDetailScrollTop.value = scrollTop;
|
||||
detailScrollHeight.value = scrollHeight;
|
||||
isTabsPinned.value = scrollTop >= tabsOffsetTop.value;
|
||||
|
||||
if (!detailSectionOffsets.value.length) {
|
||||
measureDetailSectionOffsets();
|
||||
return;
|
||||
}
|
||||
syncActiveDetailSection(scrollTop, scrollHeight);
|
||||
};
|
||||
|
||||
onMounted(measureDetailSectionOffsets);
|
||||
watch(
|
||||
() =>
|
||||
detailSections.value
|
||||
.map((section) => String(section.sectionCode ?? ""))
|
||||
.join(","),
|
||||
() => {
|
||||
const activeExists = detailSections.value.some(
|
||||
(section) =>
|
||||
String(section.sectionCode ?? "") === activeSectionCode.value
|
||||
);
|
||||
if (!activeExists) {
|
||||
activeSectionCode.value = String(
|
||||
detailSections.value[0]?.sectionCode ?? ""
|
||||
);
|
||||
}
|
||||
detailSectionScrollSelection.resumeScrollSync();
|
||||
detailSectionOffsets.value = [];
|
||||
tabsOffsetTop.value = Number.POSITIVE_INFINITY;
|
||||
isTabsPinned.value = false;
|
||||
measureDetailSectionOffsets();
|
||||
}
|
||||
);
|
||||
const currencySymbol = computed(() =>
|
||||
props.product.currency === "CNY" ? "¥" : props.product.currency || ""
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<scroll-view
|
||||
class="h-[48px] w-full whitespace-nowrap bg-white"
|
||||
scroll-x
|
||||
:scroll-into-view="activeTabId"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view class="inline-flex h-[48px] min-w-full items-center px-[8px]">
|
||||
<view
|
||||
v-for="(section, index) in sections"
|
||||
:id="`ticket-detail-tab-${instanceKey}-${index}`"
|
||||
:key="`${section.sectionCode}-${index}`"
|
||||
class="relative inline-flex h-[48px] flex-none items-center justify-center px-[14px]"
|
||||
@click="emit('select', section.sectionCode)"
|
||||
>
|
||||
<text
|
||||
class="text-[14px] leading-[20px]"
|
||||
:class="isActiveSection(section.sectionCode) ? 'font-semibold text-[#f26b32]' : 'font-normal text-slate-500'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view
|
||||
v-if="isActiveSection(section.sectionCode)"
|
||||
class="absolute bottom-0 left-1/2 h-[3px] w-[20px] -translate-x-1/2 rounded-full bg-[#f26b32]"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
sections: { type: Array, default: () => [] },
|
||||
activeSectionCode: { type: [String, Number], default: "" },
|
||||
instanceKey: { type: String, default: "default" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
const activeIndex = computed(() =>
|
||||
props.sections.findIndex(
|
||||
(section) =>
|
||||
String(section.sectionCode) === String(props.activeSectionCode)
|
||||
)
|
||||
);
|
||||
const activeTabId = computed(() =>
|
||||
activeIndex.value >= 0
|
||||
? `ticket-detail-tab-${props.instanceKey}-${activeIndex.value}`
|
||||
: ""
|
||||
);
|
||||
const isActiveSection = (sectionCode) =>
|
||||
String(sectionCode) === String(props.activeSectionCode);
|
||||
</script>
|
||||
@@ -192,6 +192,19 @@ test("TicketHome prefers mediaList and falls back to coverImage", async () => {
|
||||
assert.doesNotMatch(source, /<image\s+v-else-if="homeData\.coverImage"/);
|
||||
});
|
||||
|
||||
test("ticket detail section tabs scroll horizontally and keep the active tab visible", async () => {
|
||||
const tabs = await readTicketFile("components/TicketDetailTabs.vue");
|
||||
|
||||
assert.match(tabs, /<scroll-view/);
|
||||
assert.match(tabs, /scroll-x/);
|
||||
assert.match(tabs, /:scroll-into-view="activeTabId"/);
|
||||
assert.match(tabs, /scroll-with-animation/);
|
||||
assert.match(tabs, /v-for="\(section, index\) in sections"/);
|
||||
assert.match(tabs, /section\.title/);
|
||||
assert.match(tabs, /emit\('select', section\.sectionCode\)/);
|
||||
assert.match(tabs, /isActiveSection\(section\.sectionCode\)/);
|
||||
});
|
||||
|
||||
test("product detail is an independent prototype-aligned page", async () => {
|
||||
const indexPage = await readTicketFile("index.vue");
|
||||
const detailPage = await readTicketFile("product-detail.vue");
|
||||
@@ -214,7 +227,27 @@ test("product detail is an independent prototype-aligned page", async () => {
|
||||
assert.match(detailPage, /<ProductDetail\s+v-else-if="product"/);
|
||||
assert.match(detailPage, /\/pages-service\/tickets\/booking\?productId=/);
|
||||
assert.match(component, /import TicketMediaSwiper from "\.\/TicketMediaSwiper\.vue"/);
|
||||
assert.match(component, /import TicketDetailTabs from "\.\/TicketDetailTabs\.vue"/);
|
||||
assert.match(component, /<TicketMediaSwiper\s+:media-list="productImages"/);
|
||||
assert.equal((component.match(/<TicketDetailTabs/g) || []).length, 2);
|
||||
assert.match(component, /instance-key="inline"/);
|
||||
assert.match(component, /instance-key="pinned"/);
|
||||
assert.match(component, /v-if="isTabsPinned && detailSections\.length"/);
|
||||
assert.match(component, /id="ticket-detail-tabs-anchor"/);
|
||||
assert.match(component, /id="ticket-detail-scroll"/);
|
||||
assert.match(component, /:scroll-top="requestedScrollTop"/);
|
||||
assert.match(component, /@scroll="handleDetailScroll"/);
|
||||
assert.match(component, /@touchstart="enableDetailScrollSync"/);
|
||||
assert.match(component, /:id="`ticket-detail-section-\$\{index\}`"/);
|
||||
assert.match(component, /@ready="measureDetailSectionOffsets"/);
|
||||
assert.match(
|
||||
component,
|
||||
/uni\.createSelectorQuery\(\)\.in\(instance\?\.proxy\)/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/scrollTop \+ detailScrollViewportHeight\.value >=\s*scrollHeight - 4/
|
||||
);
|
||||
assert.match(component, /relative h-56 overflow-hidden bg-white/);
|
||||
assert.doesNotMatch(component, /1 \/ \{\{ productImages\.length \}\}/);
|
||||
assert.doesNotMatch(component, /productImages\.slice/);
|
||||
@@ -235,10 +268,28 @@ test("product detail is an independent prototype-aligned page", async () => {
|
||||
]) {
|
||||
assert.ok(component.includes(field), `${field} is not rendered`);
|
||||
}
|
||||
assert.match(component, /v-for="section in detailSections"/);
|
||||
assert.match(component, /v-for="\(section, index\) in detailSections"/);
|
||||
assert.match(component, /section\.sectionCode/);
|
||||
assert.match(component, /section\.title/);
|
||||
assert.match(component, /section\.content/);
|
||||
assert.match(
|
||||
component,
|
||||
/import MpHtml from "@\/uni_modules\/zero-markdown-view\/components\/mp-html\/mp-html\.vue"/
|
||||
);
|
||||
assert.match(component, /<MpHtml/);
|
||||
assert.match(component, /:content="section\.content \|\| ''"/);
|
||||
assert.match(component, /:preview-img="false"/);
|
||||
assert.match(component, /:show-img-menu="false"/);
|
||||
assert.match(component, /:set-title="false"/);
|
||||
assert.match(component, /:tag-style="detailContentTagStyle"/);
|
||||
assert.match(
|
||||
component,
|
||||
/img:\s*"[^"]*max-width:\s*100%[^"]*height:\s*auto/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
component,
|
||||
/<text[^>]*>\{\{\s*section\.content\s*\}\}<\/text>/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/sort\(\s*\(left, right\) => Number\(left\.sortOrder/
|
||||
@@ -250,6 +301,44 @@ test("product detail is an independent prototype-aligned page", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("product detail tab clicks win until the visitor resumes manual scrolling", async () => {
|
||||
const component = await readTicketFile("components/ProductDetail.vue");
|
||||
const factorySource = component.match(
|
||||
/const createDetailSectionScrollSelection = \(\) => \{[\s\S]*?^\};?\n/m
|
||||
)?.[0];
|
||||
|
||||
assert.ok(factorySource, "inline detail section scroll selection factory missing");
|
||||
assert.match(component, /const detailSectionScrollSelection =\s*createDetailSectionScrollSelection\(\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/const selectDetailSection = \(sectionCode\) => \{\s*activeSectionCode\.value = detailSectionScrollSelection\.select/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/const enableDetailScrollSync = \(\) => \{\s*detailSectionScrollSelection\.resumeScrollSync\(\)/
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/activeSectionCode\.value = detailSectionScrollSelection\.resolve\(/
|
||||
);
|
||||
|
||||
const createDetailSectionScrollSelection = Function(
|
||||
`${factorySource}\nreturn createDetailSectionScrollSelection`
|
||||
)();
|
||||
const selection = createDetailSectionScrollSelection();
|
||||
const clickedSectionCode = selection.select("PURCHASE_NOTICE");
|
||||
|
||||
assert.equal(
|
||||
selection.resolve(clickedSectionCode, "PRODUCT_DETAIL"),
|
||||
"PURCHASE_NOTICE"
|
||||
);
|
||||
selection.resumeScrollSync();
|
||||
assert.equal(
|
||||
selection.resolve(clickedSectionCode, "FEE_INCLUDE"),
|
||||
"FEE_INCLUDE"
|
||||
);
|
||||
});
|
||||
|
||||
test("booking flow matches the visitor prototype while preserving requested controls", async () => {
|
||||
const component = await readTicketFile("components/BookingFlow.vue");
|
||||
const page = await readTicketFile("booking.vue");
|
||||
|
||||
@@ -81,6 +81,20 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/DuohuaDiscovery/catalog",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/DuohuaDiscovery/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/test/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</view>
|
||||
|
||||
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
|
||||
<Discovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
|
||||
<DuohuaDiscovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -163,7 +163,7 @@ import { MessageRole, MessageType, CompName, Command } from "@/model/ChatModel";
|
||||
|
||||
import HomeWelcome from "../HomeWelcome/index.vue";
|
||||
import AiTabSwitch from "@/components/AiTabSwitch/index.vue";
|
||||
import Discovery from "../../Discovery/index.vue";
|
||||
import DuohuaDiscovery from "../../DuohuaDiscovery/index.vue";
|
||||
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
|
||||
import ChatGuide from "../ChatGuide/index.vue";
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
height: 42px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discovery-panel {
|
||||
position: absolute;
|
||||
top: 92px;
|
||||
top: 42px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
// bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
min-height: 0;
|
||||
|
||||
@@ -25,7 +25,7 @@ const AIGC_HOME_URL = "/pages-aigc/home/home";
|
||||
const itemList = ref([
|
||||
{
|
||||
icon: "",
|
||||
title: "快速预订",
|
||||
title: "快速领取",
|
||||
type: Command.quickBooking,
|
||||
},
|
||||
{
|
||||
@@ -60,8 +60,9 @@ const sendReply = (item) => {
|
||||
// 快速预订
|
||||
if (item.type === Command.quickBooking) {
|
||||
checkToken().then(() => {
|
||||
uni.navigateTo({ url: "/pages/DuohuaDiscovery/catalog?section=tourism" });
|
||||
// uni.navigateTo({ url: "/pages-quick/list" });
|
||||
uni.navigateTo({ url: "/pages-service/tickets/index" });
|
||||
// uni.navigateTo({ url: "/pages-service/tickets/index" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<view class="home-welcome-panel">
|
||||
<view class="home-welcome-header w-full min-w-0">
|
||||
<text class="home-welcome-title block w-0 min-w-0 truncate">
|
||||
{{ props.mainPageDataModel.welcomeContent || "小七欢迎你~" }}
|
||||
欢迎来到龙里!
|
||||
</text>
|
||||
<text v-if="weatherText" class="home-welcome-weather">
|
||||
{{ weatherText }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<NoticeMessage :tipsMessage="props.mainPageDataModel.tipsMessage" />
|
||||
<!-- <NoticeMessage :tipsMessage="props.mainPageDataModel.tipsMessage" /> -->
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-for="item in themeDTOList" :key="item.title">
|
||||
<RecommendPostsList :recommendTheme="item" />
|
||||
<RecommendPostsList v-if="item.recommendPostsList.lenght > 0" :recommendTheme="item" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -39,20 +39,21 @@ const popup = ref(null);
|
||||
const list = ref([
|
||||
{
|
||||
icon: "https://oss.nianxx.cn/mp/static/version_101/home/ksyd.png",
|
||||
title: "快速预订",
|
||||
content: "预定门票、房间、餐食",
|
||||
btnText: "去预定",
|
||||
title: "快速领取",
|
||||
content: "领取票根优惠",
|
||||
btnText: "去领取",
|
||||
type: Command.quickBooking,
|
||||
path: "/pages-quick/list",
|
||||
// path: "/pages-quick/list",
|
||||
path: "/pages/DuohuaDiscovery/catalog?section=tourism",
|
||||
},
|
||||
{
|
||||
icon: "https://oss.nianxx.cn/mp/static/version_101/home/tsfx.png",
|
||||
title: "探索发现",
|
||||
content: "发现景点、活动、特色内容",
|
||||
btnText: "去探索",
|
||||
type: Command.discovery,
|
||||
path: "",
|
||||
},
|
||||
// {
|
||||
// icon: "https://oss.nianxx.cn/mp/static/version_101/home/tsfx.png",
|
||||
// title: "探索发现",
|
||||
// content: "发现景点、活动、特色内容",
|
||||
// btnText: "去探索",
|
||||
// type: Command.discovery,
|
||||
// path: "",
|
||||
// },
|
||||
{
|
||||
icon: "https://oss.nianxx.cn/mp/static/version_101/home/mddd.png",
|
||||
title: "我的订单",
|
||||
@@ -85,14 +86,14 @@ const list = ref([
|
||||
type: Command.feedbackCard,
|
||||
path: "",
|
||||
},
|
||||
{
|
||||
icon: "https://oss.nianxx.cn/mp/static/version_101/home/hjfw.png",
|
||||
title: "呼叫热线",
|
||||
content: "我们将全程为您提供细致解答与暖心服务。",
|
||||
btnText: "去呼叫",
|
||||
type: Command.callMobileService,
|
||||
path: "",
|
||||
},
|
||||
// {
|
||||
// icon: "https://oss.nianxx.cn/mp/static/version_101/home/hjfw.png",
|
||||
// title: "呼叫热线",
|
||||
// content: "我们将全程为您提供细致解答与暖心服务。",
|
||||
// btnText: "去呼叫",
|
||||
// type: Command.callMobileService,
|
||||
// path: "",
|
||||
// },
|
||||
]);
|
||||
|
||||
const open = () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
||||
<DuohuaCatalog
|
||||
v-if="sectionData"
|
||||
:section="sectionData"
|
||||
@back="goBack"
|
||||
@open-detail="openDetail"
|
||||
/>
|
||||
<view v-else class="flex h-full flex-col">
|
||||
<TopNavBar title="" @back="goBack" />
|
||||
<view class="flex min-h-0 flex-1 items-center justify-center">
|
||||
<text class="text-[14px] text-[#808997]">暂无对应内容</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import DuohuaCatalog from "./components/DuohuaCatalog.vue";
|
||||
import { getSection } from "./data";
|
||||
|
||||
const sectionData = ref(null);
|
||||
|
||||
onLoad((options) => {
|
||||
sectionData.value = getSection(options?.section || "");
|
||||
});
|
||||
|
||||
const goBack = () => uni.navigateBack();
|
||||
|
||||
const openDetail = ({ collection, itemId }) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/DuohuaDiscovery/detail?collection=${collection}&id=${itemId}`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<view class="flex h-screen flex-col overflow-hidden bg-[#06111f]">
|
||||
<view class="relative z-0 h-[34vh] shrink-0 overflow-hidden bg-[#173a34]">
|
||||
<DuohuaMediaSwiper
|
||||
:media-list="heroMediaList"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
<view class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
|
||||
|
||||
<view class="absolute inset-x-0 top-0 z-20">
|
||||
<TopNavBar
|
||||
title=""
|
||||
background="transparent"
|
||||
title-color="#ffffff"
|
||||
back-icon-color="#ffffff"
|
||||
@back="emit('back')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="absolute bottom-[42px] left-[20px] right-[20px]">
|
||||
<view class="flex items-center gap-[10px]">
|
||||
<text class="text-[28px] font-bold leading-[1.2] text-white">
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view
|
||||
class="flex h-[27px] shrink-0 items-center rounded-full border border-white/55 bg-black/20 px-[10px]"
|
||||
@tap.stop="previewPanorama"
|
||||
>
|
||||
<text class="text-[11px] text-white">查看全景图</text>
|
||||
<uni-icons class="ml-[2px]" type="right" size="12" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="mt-[7px] block text-[13px] leading-[19px] text-white/85">
|
||||
{{ region }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="relative z-10 -mt-[28px] min-h-0 flex-1 overflow-hidden rounded-t-[28px] bg-[#f4f6f8] shadow-[0_-8px_24px_rgba(6,17,31,0.12)]">
|
||||
<view class="flex h-full min-h-0">
|
||||
<scroll-view
|
||||
class="h-full w-[86px] shrink-0 bg-white"
|
||||
scroll-y
|
||||
:scroll-into-view="leftScrollIntoViewId"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view
|
||||
v-for="group in groups"
|
||||
:id="`duohua-nav-${group.groupId}`"
|
||||
:key="group.groupId"
|
||||
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
|
||||
:class="isActiveGroup(group.groupId) ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
|
||||
@click="selectGroup(group.groupId)"
|
||||
>
|
||||
<view
|
||||
v-if="isActiveGroup(group.groupId)"
|
||||
class="absolute left-0 h-[28px] w-[3px] rounded-r-full bg-[#149474]"
|
||||
/>
|
||||
<text
|
||||
class="text-[13px] leading-[18px]"
|
||||
:class="isActiveGroup(group.groupId) ? 'font-semibold' : 'font-normal'"
|
||||
>
|
||||
{{ group.type }}
|
||||
</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view
|
||||
id="duohua-content-scroll"
|
||||
class="h-full min-w-0 flex-1 bg-[#f4f6f8]"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollIntoViewId"
|
||||
scroll-with-animation
|
||||
@scroll="handleContentScroll"
|
||||
@touchstart="enableContentScrollSync"
|
||||
>
|
||||
<view class="px-[12px] pb-[24px]">
|
||||
<view
|
||||
v-for="group in groups"
|
||||
:id="`duohua-group-${group.groupId}`"
|
||||
:key="group.groupId"
|
||||
class="pt-[14px]"
|
||||
>
|
||||
<text class="block text-[18px] font-bold text-[#202733]">
|
||||
{{ group.type }}
|
||||
</text>
|
||||
|
||||
<view class="mt-[10px] flex flex-col gap-[10px]">
|
||||
<view
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="flex rounded-[14px] bg-white p-[10px]"
|
||||
@click="openDetail(group.collection, item.id)"
|
||||
>
|
||||
<image
|
||||
class="h-[100px] w-[100px] shrink-0 rounded-[10px] bg-[#e8edf0]"
|
||||
:src="item.main_image_url"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="ml-[10px] flex min-w-0 flex-1 flex-col">
|
||||
<view class="flex min-w-0 items-start">
|
||||
<text class="line-clamp-2 min-w-0 flex-1 text-[14px] font-semibold leading-[20px] text-[#242b36]">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<view class="flex h-[20px] shrink-0 items-center">
|
||||
<uni-icons type="right" size="14" color="#242b36" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.tags?.length" class="mt-[5px] flex flex-wrap gap-[4px]">
|
||||
<text
|
||||
v-for="tag in item.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]"
|
||||
>
|
||||
{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="item.description"
|
||||
class="mt-[6px] line-clamp-2 text-[11px] leading-[17px] text-[#808997]"
|
||||
>
|
||||
{{ item.description }}
|
||||
</text>
|
||||
<view v-if="addressText(item)" class="mt-auto flex items-center pt-[5px]">
|
||||
<uni-icons type="location" size="12" color="#9aa2ad" />
|
||||
<text class="ml-[2px] min-w-0 flex-1 truncate text-[10px] text-[#9aa2ad]">
|
||||
{{ addressText(item) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import DuohuaMediaSwiper from "./DuohuaMediaSwiper.vue";
|
||||
import { sourceData } from "../data";
|
||||
|
||||
const props = defineProps({
|
||||
section: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["back", "open-detail"]);
|
||||
const groups = computed(() => props.section.groups || []);
|
||||
const region = sourceData.metadata?.region || "";
|
||||
const activeGroupId = ref(groups.value[0]?.groupId || "");
|
||||
const scrollIntoViewId = ref("");
|
||||
const leftScrollIntoViewId = computed(
|
||||
() => `duohua-nav-${activeGroupId.value}`
|
||||
);
|
||||
const groupOffsets = ref([]);
|
||||
const currentScrollTop = ref(0);
|
||||
const shouldSyncFromScroll = ref(true);
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const heroMediaList = computed(() =>
|
||||
groups.value
|
||||
.map((group) =>
|
||||
group.items.find((item) => item.main_image_url)?.main_image_url
|
||||
)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const isActiveGroup = (groupId) => groupId === activeGroupId.value;
|
||||
const addressText = (item) =>
|
||||
item?.address?.raw || item?.address?.normalized || "";
|
||||
|
||||
const selectGroup = (groupId) => {
|
||||
shouldSyncFromScroll.value = false;
|
||||
activeGroupId.value = groupId;
|
||||
scrollIntoViewId.value = "";
|
||||
nextTick(() => {
|
||||
scrollIntoViewId.value = `duohua-group-${groupId}`;
|
||||
});
|
||||
};
|
||||
|
||||
const enableContentScrollSync = () => {
|
||||
shouldSyncFromScroll.value = true;
|
||||
scrollIntoViewId.value = "";
|
||||
};
|
||||
|
||||
const measureGroupOffsets = () => {
|
||||
if (!groups.value.length) return;
|
||||
|
||||
nextTick(() => {
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||
query.select("#duohua-content-scroll").boundingClientRect();
|
||||
groups.value.forEach((group) => {
|
||||
query
|
||||
.select(`#duohua-group-${group.groupId}`)
|
||||
.boundingClientRect();
|
||||
});
|
||||
query.exec((rects = []) => {
|
||||
const containerRect = rects[0];
|
||||
if (!containerRect) return;
|
||||
groupOffsets.value = groups.value
|
||||
.map((group, index) => ({
|
||||
id: group.groupId,
|
||||
top: Math.max(
|
||||
0,
|
||||
Number(rects[index + 1]?.top || 0) -
|
||||
Number(containerRect.top || 0) +
|
||||
currentScrollTop.value
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.top - right.top);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleContentScroll = (event) => {
|
||||
currentScrollTop.value = Number(event.detail?.scrollTop || 0);
|
||||
if (!groupOffsets.value.length) {
|
||||
measureGroupOffsets();
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncFromScroll.value) return;
|
||||
|
||||
const activationLine = currentScrollTop.value + 24;
|
||||
const activeGroup = groupOffsets.value.reduce(
|
||||
(current, group) => (group.top <= activationLine ? group : current),
|
||||
groupOffsets.value[0]
|
||||
);
|
||||
if (activeGroup?.id) activeGroupId.value = activeGroup.id;
|
||||
};
|
||||
|
||||
const previewPanorama = () => {
|
||||
if (!heroMediaList.value.length) return;
|
||||
uni.previewImage({
|
||||
current: heroMediaList.value[0],
|
||||
urls: heroMediaList.value,
|
||||
});
|
||||
};
|
||||
|
||||
const openDetail = (collection, itemId) => {
|
||||
emit("open-detail", { collection, itemId });
|
||||
};
|
||||
|
||||
onMounted(measureGroupOffsets);
|
||||
watch(
|
||||
() => groups.value.map((group) => group.groupId).join(","),
|
||||
() => {
|
||||
activeGroupId.value = groups.value[0]?.groupId || "";
|
||||
groupOffsets.value = [];
|
||||
measureGroupOffsets();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view class="box-border w-full px-[12px] pb-[20px] pt-[12px]">
|
||||
<view
|
||||
v-if="mainSection"
|
||||
class="relative h-[162px] w-full overflow-hidden rounded-[18px] border border-white bg-[#17463d]"
|
||||
hover-class="opacity-90"
|
||||
hover-stay-time="80"
|
||||
@tap="selectSection(mainSection)"
|
||||
>
|
||||
<image
|
||||
class="absolute inset-0 h-full w-full"
|
||||
:src="mainSection.coverImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="absolute inset-0 bg-[linear-gradient(90deg,rgba(3,39,33,0.78)_0%,rgba(3,39,33,0.34)_55%,rgba(3,39,33,0.08)_100%)]" />
|
||||
<view class="absolute inset-0 box-border flex flex-col items-start justify-center px-[20px] py-[16px]">
|
||||
<text class="block text-[27px] font-bold leading-[1.15] tracking-[1px] text-white">
|
||||
{{ mainSection.title }}
|
||||
</text>
|
||||
<text class="mt-[7px] block text-[12px] leading-[1.4] tracking-[0.5px] text-white/90">
|
||||
{{ mainSection.subtitle }}
|
||||
</text>
|
||||
<view class="mt-[12px] flex h-[36px] items-center rounded-full bg-white/95 px-[16px]">
|
||||
<text class="mr-[4px] text-[14px] font-semibold text-[#149474]">开始探索</text>
|
||||
<uni-icons type="arrow-right" size="16" color="#149474" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="lowerSections.length" class="mt-[12px] grid grid-cols-12 gap-[10px]">
|
||||
<view
|
||||
v-if="leftSection"
|
||||
class="relative col-span-6 row-span-2 min-h-0 overflow-hidden rounded-[18px] border border-white bg-[#173a34]"
|
||||
hover-class="opacity-90"
|
||||
hover-stay-time="80"
|
||||
@tap="selectSection(leftSection)"
|
||||
>
|
||||
<swiper
|
||||
class="h-full w-full"
|
||||
circular
|
||||
autoplay
|
||||
:interval="3500"
|
||||
:duration="500"
|
||||
@change="handleLodgingSwiperChange"
|
||||
>
|
||||
<swiper-item v-for="imageUrl in lodgingImages" :key="imageUrl">
|
||||
<image class="h-full w-full" :src="imageUrl" mode="aspectFill" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(5,24,24,0.72)_0%,rgba(5,24,24,0.18)_46%,rgba(5,24,24,0.34)_100%)]" />
|
||||
<view class="pointer-events-none absolute left-[16px] right-[14px] top-[17px]">
|
||||
<text class="block text-[21px] font-bold leading-[1.2] tracking-[0.5px] text-white">
|
||||
{{ leftSection.title }}
|
||||
</text>
|
||||
<text class="mt-[7px] block text-[11px] leading-[1.45] tracking-[0.2px] text-white/85">
|
||||
{{ leftSection.subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="pointer-events-none absolute bottom-[16px] left-[16px] flex h-[46px] w-[46px] items-center justify-center rounded-full border border-[#149474] bg-[rgba(0,0,0,0.3)]">
|
||||
<uni-icons type="arrow-right" size="22" color="#FFFFFF" />
|
||||
</view>
|
||||
<view
|
||||
v-if="lodgingImages.length > 1"
|
||||
class="pointer-events-none absolute bottom-[20px] right-[14px] flex items-center gap-[5px]"
|
||||
>
|
||||
<view
|
||||
v-for="(imageUrl, index) in lodgingImages"
|
||||
:key="`${imageUrl}-dot`"
|
||||
class="h-[5px] rounded-full transition-all duration-300"
|
||||
:class="index === activeLodgingIndex ? 'w-[14px] bg-white' : 'w-[5px] bg-white/45'"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="section in rightSections"
|
||||
:key="section.key"
|
||||
class="relative col-span-6 h-[130px] min-h-0 overflow-hidden rounded-[18px] border border-white bg-[#173a34]"
|
||||
hover-class="opacity-90"
|
||||
hover-stay-time="80"
|
||||
@tap="selectSection(section)"
|
||||
>
|
||||
<image class="h-full w-full" :src="section.coverImage" mode="aspectFill" />
|
||||
<view class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(5,24,24,0.72)_0%,rgba(5,24,24,0.18)_46%,rgba(5,24,24,0.34)_100%)]" />
|
||||
<view class="pointer-events-none absolute left-[12px] right-[10px] top-[11px]">
|
||||
<text class="block text-[17px] font-bold leading-[1.2] tracking-[0.5px] text-white">
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<text class="mt-[3px] block text-[9px] leading-[1.45] tracking-[0.2px] text-white/85">
|
||||
{{ section.subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="pointer-events-none absolute bottom-[10px] left-[12px] flex h-[46px] w-[46px] items-center justify-center rounded-full border border-white/20 bg-[rgba(0,0,0,0.3)]">
|
||||
<uni-icons type="arrow-right" size="22" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { sections } from "../data";
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
const mainSection = computed(() => sections[0] || null);
|
||||
const lowerSections = computed(() => sections.slice(1));
|
||||
const leftSection = computed(() => lowerSections.value[0] || null);
|
||||
const rightSections = computed(() => lowerSections.value.slice(1, 3));
|
||||
const activeLodgingIndex = ref(0);
|
||||
const lodgingImages = computed(() =>
|
||||
(leftSection.value?.groups || [])
|
||||
.map((group) =>
|
||||
group.items.find((item) => item.main_image_url)?.main_image_url
|
||||
)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const handleLodgingSwiperChange = (event) => {
|
||||
activeLodgingIndex.value = Number(event.detail?.current || 0);
|
||||
};
|
||||
|
||||
const selectSection = (section) => emit("select", section);
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<view class="relative h-full w-full overflow-hidden">
|
||||
<swiper
|
||||
v-if="images.length"
|
||||
class="h-full w-full"
|
||||
circular
|
||||
:indicator-dots="false"
|
||||
@change="handleChange"
|
||||
>
|
||||
<swiper-item v-for="imageUrl in images" :key="imageUrl">
|
||||
<image
|
||||
class="h-full w-full"
|
||||
:src="imageUrl"
|
||||
mode="aspectFill"
|
||||
@tap="previewImage(imageUrl)"
|
||||
/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view v-else class="flex h-full w-full items-center justify-center bg-[#dfe8e5]">
|
||||
<text class="text-[12px] text-[#73827d]">暂无图片</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="images.length > 1"
|
||||
class="absolute bottom-[12px] right-[14px] rounded-full bg-black/45 px-[9px] py-[4px]"
|
||||
>
|
||||
<text class="text-[11px] text-white">{{ activeIndex + 1 }}/{{ images.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mediaList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const activeIndex = ref(0);
|
||||
const images = computed(() =>
|
||||
props.mediaList
|
||||
.filter((item) => typeof item === "string" && item.trim())
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
);
|
||||
|
||||
const handleChange = (event) => {
|
||||
activeIndex.value = Number(event.detail?.current || 0);
|
||||
};
|
||||
|
||||
const previewImage = (current) => {
|
||||
uni.previewImage({ current, urls: images.value });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
import sourceData from "./miniprogram_data.json";
|
||||
|
||||
const SECTION_DEFINITIONS = [
|
||||
{
|
||||
key: "tourism",
|
||||
title: "龙里票根经济",
|
||||
subtitle: "领取龙里旅游优惠",
|
||||
coverImage:
|
||||
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/01.jpg",
|
||||
collections: ["attractions", "attraction_subprojects"],
|
||||
supportAssetIds: ["tourism-overview-map", "tourism-guide-qr"],
|
||||
},
|
||||
{
|
||||
key: "lodging",
|
||||
title: "旅居服务",
|
||||
subtitle: "酒店 · 民宿别院 · 旅居公寓 · 露营基地",
|
||||
collections: ["accommodations"],
|
||||
supportAssetIds: ["lodging-location-schematic", "lodging-guide-qr"],
|
||||
},
|
||||
{
|
||||
key: "food",
|
||||
title: "全域美食",
|
||||
subtitle: "特色菜肴 · 地方小吃 · 甜品饮品",
|
||||
collections: ["foods"],
|
||||
supportAssetIds: ["food-guide-qr"],
|
||||
},
|
||||
{
|
||||
key: "specialties",
|
||||
title: "龙里特产",
|
||||
subtitle: "刺梨制品 · 地方食品 · 农产品",
|
||||
collections: ["specialties"],
|
||||
supportAssetIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeImageList = (item) => {
|
||||
const imageUrls = Array.isArray(item?.images)
|
||||
? item.images.map((image) => image?.oss_url)
|
||||
: [];
|
||||
|
||||
return [item?.main_image_url, ...imageUrls, ...(item?.gallery_urls || [])]
|
||||
.filter((url) => typeof url === "string" && url.trim())
|
||||
.filter((url, index, list) => list.indexOf(url) === index);
|
||||
};
|
||||
|
||||
const getCollectionItems = (collection) =>
|
||||
Array.isArray(sourceData?.[collection]) ? sourceData[collection] : [];
|
||||
|
||||
const getSectionGroups = (sectionKey) => {
|
||||
const section = SECTION_DEFINITIONS.find((item) => item.key === sectionKey);
|
||||
if (!section) return [];
|
||||
|
||||
return section.collections.flatMap((collection) => {
|
||||
const groups = Array.isArray(sourceData?.catalog_by_type?.[collection])
|
||||
? sourceData.catalog_by_type[collection]
|
||||
: [];
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
collection,
|
||||
groupId: `${collection}-${group.type_code}`,
|
||||
items: Array.isArray(group.items) ? group.items : [],
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const getSection = (sectionKey) => {
|
||||
const definition = SECTION_DEFINITIONS.find(
|
||||
(item) => item.key === sectionKey
|
||||
);
|
||||
if (!definition) return null;
|
||||
|
||||
const groups = getSectionGroups(sectionKey);
|
||||
const items = groups.flatMap((group) => group.items);
|
||||
const supportAssets = (sourceData?.assets || []).filter((asset) =>
|
||||
definition.supportAssetIds.includes(asset.entity_id)
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
groups,
|
||||
items,
|
||||
supportAssets,
|
||||
coverImage:
|
||||
definition.coverImage ||
|
||||
items.find((item) => item.main_image_url)?.main_image_url ||
|
||||
"",
|
||||
};
|
||||
};
|
||||
|
||||
const getItem = (collection, itemId) =>
|
||||
getCollectionItems(collection).find((item) => item.id === itemId) || null;
|
||||
|
||||
const getSectionForCollection = (collection) =>
|
||||
SECTION_DEFINITIONS.find((section) =>
|
||||
section.collections.includes(collection)
|
||||
) || null;
|
||||
|
||||
const sections = SECTION_DEFINITIONS.map((section) => getSection(section.key));
|
||||
|
||||
export {
|
||||
getItem,
|
||||
getSection,
|
||||
getSectionForCollection,
|
||||
getSectionGroups,
|
||||
normalizeImageList,
|
||||
sections,
|
||||
sourceData,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f4f6f8]">
|
||||
<TopNavBar
|
||||
:title="item?.name || ''"
|
||||
:subtitle="sectionTitle"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
|
||||
<scroll-view v-if="item" class="min-h-0 flex-1" scroll-y>
|
||||
<view class="h-[240px] overflow-hidden bg-[#dfe8e5]">
|
||||
<DuohuaMediaSwiper :media-list="imageList" />
|
||||
</view>
|
||||
|
||||
<view class="bg-white px-[16px] pb-[18px] pt-[16px]">
|
||||
<view class="flex items-center gap-[8px]">
|
||||
<text class="min-w-0 text-[22px] font-bold leading-[30px] text-[#172033]">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text
|
||||
v-if="item.grade"
|
||||
class="shrink-0 rounded-[5px] bg-[#e9f7f1] px-[7px] py-[3px] text-[11px] font-semibold text-[#149474]"
|
||||
>
|
||||
{{ item.grade }}
|
||||
</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="item.name_en"
|
||||
class="mt-[3px] block text-[12px] leading-[18px] text-[#808997]"
|
||||
>
|
||||
{{ item.name_en }}
|
||||
</text>
|
||||
<view v-if="displayAddress" class="mt-[10px] flex items-center">
|
||||
<uni-icons class="shrink-0" type="location" size="15" color="#808997" />
|
||||
<text class="ml-[4px] min-w-0 flex-1 text-[12px] leading-[19px] text-[#6f7884]">
|
||||
{{ displayAddress }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="contactText || canOpenLocation" class="mt-[14px] flex items-center gap-[10px]">
|
||||
<view v-if="contactText" class="flex min-w-0 items-center gap-[8px]">
|
||||
<text class="min-w-0 text-[13px] font-medium text-[#4f5966]">
|
||||
{{ contactText }}
|
||||
</text>
|
||||
<view
|
||||
class="flex h-[36px] shrink-0 items-center rounded-full bg-[#ecf8f2] px-[14px]"
|
||||
@click="makePhoneCall"
|
||||
>
|
||||
<uni-icons type="phone" size="15" color="#149474" />
|
||||
<text class="ml-[5px] text-[12px] font-medium text-[#149474]">拨打电话</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="canOpenLocation"
|
||||
class="flex h-[36px] items-center rounded-full bg-[#ecf8f2] px-[14px]"
|
||||
@click="openLocation"
|
||||
>
|
||||
<uni-icons type="navigate" size="15" color="#149474" />
|
||||
<text class="ml-[5px] text-[12px] font-medium text-[#149474]">地图导航</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.tags?.length" class="mt-[14px] flex flex-wrap gap-[6px]">
|
||||
<text
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
class="rounded-[4px] bg-[#e8f2ff] px-[6px] py-[3px] text-[10px] text-[#3474c7]"
|
||||
>
|
||||
{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="item.description" class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block text-[16px] font-semibold text-[#202733]">介绍</text>
|
||||
<text class="mt-[10px] block text-[14px] leading-[23px] text-[#5f6875]">
|
||||
{{ item.description }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="section in arraySections"
|
||||
:key="section.title"
|
||||
class="mx-[12px] mt-[12px] rounded-[16px] bg-white p-[16px]"
|
||||
>
|
||||
<text class="block text-[16px] font-semibold text-[#202733]">
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view class="mt-[9px] flex flex-wrap gap-[7px]">
|
||||
<text
|
||||
v-for="(value, index) in section.values"
|
||||
:key="`${section.title}-${index}`"
|
||||
class="rounded-[6px] bg-[#f2f6f5] px-[9px] py-[6px] text-[12px] leading-[18px] text-[#52605c]"
|
||||
>
|
||||
{{ formatValue(value) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="h-[24px]" />
|
||||
</scroll-view>
|
||||
|
||||
<view v-else class="flex min-h-0 flex-1 items-center justify-center">
|
||||
<text class="text-[14px] text-[#808997]">未找到对应内容</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="item"
|
||||
class="shrink-0 border-t border-[#edf0f2] bg-white px-[16px] pb-[calc(env(safe-area-inset-bottom)+10px)] pt-[10px] shadow-[0_-4px_16px_rgba(23,32,51,0.06)]"
|
||||
>
|
||||
<view class="flex items-center">
|
||||
<view class="flex min-w-0 flex-1 items-center">
|
||||
<view class="flex h-[38px] w-[38px] shrink-0 items-center justify-center rounded-full bg-[#fff1e8]">
|
||||
<uni-icons type="gift" size="20" color="#f26b32" />
|
||||
</view>
|
||||
<text class="ml-[10px] text-[16px] font-semibold text-[#202733]">领取票根优惠</text>
|
||||
</view>
|
||||
<view
|
||||
class="flex h-[36px] shrink-0 items-center justify-center rounded-full bg-[#f26b32] px-[18px]"
|
||||
@click="claimCoupon"
|
||||
>
|
||||
<text class="text-[12px] font-medium text-white">立即领取</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import DuohuaMediaSwiper from "./components/DuohuaMediaSwiper.vue";
|
||||
import {
|
||||
getItem,
|
||||
getSectionForCollection,
|
||||
normalizeImageList,
|
||||
} from "./data";
|
||||
|
||||
const item = ref(null);
|
||||
const collection = ref("");
|
||||
const sectionTitle = computed(
|
||||
() => getSectionForCollection(collection.value)?.title || ""
|
||||
);
|
||||
const imageList = computed(() => normalizeImageList(item.value));
|
||||
const contactText = computed(
|
||||
() => item.value?.contact?.normalized || item.value?.contact?.raw || ""
|
||||
);
|
||||
const displayAddress = computed(() => {
|
||||
const address = item.value?.address;
|
||||
if (!address) return "";
|
||||
return address.normalized || "";
|
||||
});
|
||||
const canOpenLocation = computed(() => {
|
||||
const latitudeValue = item.value?.address?.latitude;
|
||||
const longitudeValue = item.value?.address?.longitude;
|
||||
if (
|
||||
latitudeValue === null ||
|
||||
latitudeValue === undefined ||
|
||||
latitudeValue === "" ||
|
||||
longitudeValue === null ||
|
||||
longitudeValue === undefined ||
|
||||
longitudeValue === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const latitude = Number(latitudeValue);
|
||||
const longitude = Number(longitudeValue);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude);
|
||||
});
|
||||
|
||||
const arraySections = computed(() => {
|
||||
if (!item.value) return [];
|
||||
return [
|
||||
{ title: "房型", values: item.value.room_types },
|
||||
{ title: "设施服务", values: item.value.facilities },
|
||||
{ title: "主要食材", values: item.value.ingredients },
|
||||
{ title: "烹饪方式", values: item.value.cooking_methods },
|
||||
{ title: "营养信息", values: item.value.nutrition_facts },
|
||||
].filter((section) => Array.isArray(section.values) && section.values.length);
|
||||
});
|
||||
|
||||
const formatValue = (value) => {
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value)
|
||||
.filter(([, itemValue]) => itemValue !== "" && itemValue !== null)
|
||||
.map(([key, itemValue]) => `${key}:${itemValue}`)
|
||||
.join(";");
|
||||
}
|
||||
return String(value ?? "");
|
||||
};
|
||||
|
||||
const makePhoneCall = () => {
|
||||
if (!contactText.value) return;
|
||||
uni.makePhoneCall({ phoneNumber: contactText.value });
|
||||
};
|
||||
|
||||
const claimCoupon = () => {
|
||||
uni.showToast({
|
||||
title: "领取成功",
|
||||
icon: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const openLocation = () => {
|
||||
if (!canOpenLocation.value) return;
|
||||
uni.openLocation({
|
||||
latitude: Number(item.value.address.latitude),
|
||||
longitude: Number(item.value.address.longitude),
|
||||
name: item.value.name,
|
||||
address: item.value.address.raw || item.value.address.normalized || "",
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
collection.value = options?.collection || "";
|
||||
item.value = getItem(collection.value, options?.id || "");
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<view class="flex h-full min-h-0 w-full flex-col bg-[#F0F8F3]">
|
||||
<scroll-view
|
||||
class="h-0 min-h-0 w-full flex-1 overflow-hidden"
|
||||
scroll-y
|
||||
@scroll="handleContentScroll"
|
||||
>
|
||||
<DuohuaHome @select="openSection" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DuohuaHome from "./components/DuohuaHome.vue";
|
||||
|
||||
defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["content-scroll"]);
|
||||
|
||||
const handleContentScroll = (event) => {
|
||||
emit("content-scroll", {
|
||||
scrollTop: Number(event.detail?.scrollTop || 0),
|
||||
});
|
||||
};
|
||||
|
||||
const openSection = (section) => {
|
||||
if (!section?.key) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/DuohuaDiscovery/catalog?section=${section.key}`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
+10
-10
@@ -13,16 +13,16 @@
|
||||
*/
|
||||
|
||||
/* 主题颜色(由 switch-client 命令自动更新) */
|
||||
$theme-color-900: #19428F;
|
||||
$theme-color-800: #174BB6;
|
||||
$theme-color-700: #145EE1;
|
||||
$theme-color-600: #1B74F5;
|
||||
$theme-color-500: #2D91FF;
|
||||
$theme-color-400: #59B4FF;
|
||||
$theme-color-300: #8ED0FF;
|
||||
$theme-color-200: #BCE1FF;
|
||||
$theme-color-100: #D9EEFF;
|
||||
$theme-color-50: #EEF8FF;
|
||||
$theme-color-900: #0B5C2D;
|
||||
$theme-color-800: #0B7034;
|
||||
$theme-color-700: #0B5C2D;
|
||||
$theme-color-600: #02C34E;
|
||||
$theme-color-500: #0CCD58;
|
||||
$theme-color-400: #35F37F;
|
||||
$theme-color-300: #77FEAB;
|
||||
$theme-color-200: #A0E5BA;
|
||||
$theme-color-100: #E8FFF1;
|
||||
$theme-color-50: #F0F8F3;
|
||||
|
||||
// text 颜色
|
||||
$text-color-900: #181b25;
|
||||
|
||||
Reference in new issue
Block a user