feat: add heritage discovery tab and AIGC consent dialog

- Add new HeritageDiscoverySection and HeritageQuickPrompts components for dedicated heritage discovery view
- Implement reusable AigcConsentDialog component for AI service user agreement prompts
- Update Discovery page to toggle between explore and heritage tab layouts
- Refactor legacy CardSwiper component into generic ExploreCards
- Adjust ChatMainList layout to include home hero sprite animation and restructure welcome section
- Refine NoticeMessage component styling and layout for better consistency
- Add build best practices guidance to AGENTS.md documentation
- Clean up minor config whitespace and formatting issues across files
This commit is contained in:
duanshuwen
2026-07-05 14:56:55 +08:00
parent 251f92bc1a
commit ca680ab41b
16 changed files with 1021 additions and 125 deletions

View File

@@ -0,0 +1,94 @@
<template>
<view
v-if="visible"
class="aigc-consent-overlay"
@touchmove.stop.prevent
>
<view class="aigc-consent-dialog">
<text class="aigc-consent-title">温馨提示</text>
<view class="aigc-consent-content">
<text v-if="content">{{ content }}</text>
<template v-else>
<text>{{ defaultContentPrefix }}</text>
<text class="aigc-consent-rule" @tap.stop="handleRuleClick">
{{ ruleName }}
</text>
<text>{{ defaultContentSuffix }}</text>
</template>
</view>
<view class="aigc-consent-actions">
<button
class="aigc-consent-button aigc-consent-cancel"
type="default"
@tap.stop="handleCancel"
>
取消
</button>
<button
class="aigc-consent-button aigc-consent-agree"
type="default"
@tap.stop="handleAgree"
>
我同意
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
companyName: {
type: String,
default: "智念科技服务有限公司",
},
ruleName: {
type: String,
default: "智念AI用户规则",
},
content: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:visible", "cancel", "agree", "ruleClick"]);
const defaultContentPrefix = computed(
() =>
`欢迎使用【${props.companyName}】提供的AI内容升级服务。本服务通过人工智能技术AIGC将您上传的照片或视频与景区场景模板进行创意优化为您生成具有特定风格的旅行内容。请您仔细阅读`
);
const defaultContentSuffix =
",您点击“我同意”等进行下一步操作的行为均视为您同意受本规则约束。如您不同意,您可以直接关闭页面。";
const close = () => {
emit("update:visible", false);
};
const handleCancel = () => {
close();
emit("cancel");
};
const handleAgree = () => {
close();
emit("agree");
};
const handleRuleClick = () => {
emit("ruleClick");
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,86 @@
.aigc-consent-overlay {
position: absolute;
z-index: 30;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 28px;
background: rgba(0, 0, 0, 0.58);
box-sizing: border-box;
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
}
.aigc-consent-dialog {
width: 100%;
padding: 34px 28px 26px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26);
box-sizing: border-box;
}
.aigc-consent-title {
display: block;
margin-bottom: 24px;
color: #111827;
font-size: 19px;
line-height: 26px;
font-weight: 900;
text-align: center;
}
.aigc-consent-content {
color: #606776;
font-size: 14px;
line-height: 25px;
font-weight: 700;
text-align: justify;
}
.aigc-consent-rule {
color: #18bd78;
font-weight: 900;
}
.aigc-consent-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 28px;
}
.aigc-consent-button {
width: 100%;
height: 54px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 999px;
font-size: 16px;
line-height: 22px;
font-weight: 900;
box-sizing: border-box;
}
.aigc-consent-button::after {
border: 0;
}
.aigc-consent-cancel {
border: 1px solid #e2e6ea;
background: #ffffff;
color: #172033;
}
.aigc-consent-agree {
border: 1px solid #061c31;
background: #061c31;
color: #ffffff;
}

View File

@@ -9,13 +9,24 @@
/>
</view>
<view class="relative">
<image class="w-full block" :src="mainPageDataModel?.initPageImages?.backgroundImageUrl"
mode="aspectFill" style="height: 252px;" />
<view class="absolute bottom-0 left-0 right-0 flex-full">
<view class="px-12 pt-12" style="margin-bottom: -1.5px;">
<HomeWelcome :mainPageDataModel="mainPageDataModel" />
</view>
<view class="home-hero relative flex-shrink-0">
<image
class="home-hero-bg w-full block"
:src="mainPageDataModel?.initPageImages?.backgroundImageUrl"
mode="aspectFill"
/>
<SpriteAnimator
v-if="homeSpriteStyle.ipLargeImage"
class="home-hero-sprite"
:src="homeSpriteStyle.ipLargeImage"
:frameWidth="homeSpriteStyle.frameWidth"
:frameHeight="homeSpriteStyle.frameHeight"
:totalFrames="homeSpriteStyle.totalFrames"
:columns="homeSpriteStyle.columns"
:displayWidth="homeSpriteStyle.displayWidth"
:fps="16"
/>
<view class="home-tabs-layer absolute bottom-0 left-0 right-0 flex-full">
<view style="margin-bottom: -1px;">
<AiTabSwitch v-model="tabIndex" :list="tabList" @change="handleChange" />
</view>
@@ -24,15 +35,21 @@
<view
v-show="tabIndex === 0"
class="main-scroll-area flex-full overflow-hidden min-height-0"
class="main-scroll-area flex flex-col flex-full overflow-hidden min-height-0"
@touchstart.capture="handleScrollAreaTouchStart"
@touchstart="handleScrollAreaTouchStart"
@touchmove="handleScrollAreaTouchMove"
>
<Discovery
@scroll-touch-start="handleScrollAreaTouchStart"
@scroll-touch="handleScrollAreaTouchMove"
<HomeWelcome
class="flex-shrink-0"
:mainPageDataModel="mainPageDataModel"
/>
<view class="flex-full min-height-0 overflow-hidden">
<Discovery
@scroll-touch-start="handleScrollAreaTouchStart"
@scroll-touch="handleScrollAreaTouchMove"
/>
</view>
</view>
<!-- 消息列表可滚动区域 -->
@@ -222,7 +239,7 @@
</template>
<script setup>
import { onMounted, nextTick, onUnmounted, ref } from "vue";
import { computed, onMounted, nextTick, onUnmounted, ref } from "vue";
import { onLoad, onReady } from "@dcloudio/uni-app";
import {
SWITCH_TO_COMPANION_TAB,
@@ -238,6 +255,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 SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
import ChatGuide from "../ChatGuide/index.vue";
import ChatTopNavBar from "../ChatTopNavBar/index.vue";
@@ -351,6 +369,18 @@ let currentSessionMessageId = null;
const tabIndex = ref(0);
const tabList = ["探索发现", "AI伴游"];
const homeSpriteStyle = computed(() => {
const images = mainPageDataModel.value?.initPageImages || {};
return {
ipLargeImage: images.ipLargeImage,
frameWidth: images.ipLargeImageWidth,
frameHeight: images.ipLargeImageHeight,
totalFrames: images.ipLargeTotalFrames,
columns: images.ipLargeColumns,
displayWidth: 105,
};
});
const handleChange = (i) => {
console.log("切换:", i);
};

View File

@@ -3,4 +3,26 @@
height: 0;
min-height: 0;
overscroll-behavior-y: contain;
}
}
.home-hero {
height: 136px;
overflow: hidden;
}
.home-hero-bg {
height: 136px;
}
.home-hero-sprite {
position: absolute;
left: 50%;
bottom: 0;
z-index: 4;
transform: translateX(-50%);
pointer-events: none;
}
.home-tabs-layer {
z-index: 3;
}

View File

@@ -1,27 +1,23 @@
<template>
<view class="flex border-box w-full">
<view class="flex flex-col flex-full min-width-0 mr-12">
<view class="flex flex-row flex-items-center flex-justify-between min-width-0">
<text class="flex-full min-width-0 font-size-20 font-600 color-white ellipsis-1">{{ mainPageDataModel.welcomeContent }}</text>
<text class="flex-shrink-0 font-size-20 font-600 color-white mt-4"> {{ weatherText }} </text>
</view>
<NoticeMessage :tipsMessage="props.mainPageDataModel.tipsMessage" />
<view class="home-welcome-panel">
<view class="home-welcome-header">
<text class="home-welcome-title ellipsis-1">
{{ props.mainPageDataModel.welcomeContent || "小七欢迎你~" }}
</text>
<text v-if="weatherText" class="home-welcome-weather">
{{ weatherText }}
</text>
</view>
<SpriteAnimator class="sprite-self-end" :src="spriteStyle.ipLargeImage" :frameWidth="spriteStyle.frameWidth"
:frameHeight="spriteStyle.frameHeight" :totalFrames="spriteStyle.totalFrames" :columns="spriteStyle.columns"
:displayWidth="spriteStyle.displayWidth" :fps="16" />
<NoticeMessage :tipsMessage="props.mainPageDataModel.tipsMessage" />
</view>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { defineProps, computed } from "vue";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
import NoticeMessage from "../NoticeMessage/index.vue";
import { getLocalWeather } from "@/request/api/MainPageDataApi";
const weatherText = ref('');
const weatherText = ref("");
const props = defineProps({
mainPageDataModel: {
@@ -38,23 +34,6 @@ const props = defineProps({
},
});
const initPageImages = computed(() => {
return props.mainPageDataModel?.initPageImages || {};
});
const spriteStyle = computed(() => {
const images = initPageImages.value;
return {
backgroundImageUrl: images.backgroundImageUrl,
ipLargeImage: images.ipLargeImage,
frameWidth: images.ipLargeImageWidth,
frameHeight: images.ipLargeImageHeight,
totalFrames: images.ipLargeTotalFrames,
columns: images.ipLargeColumns,
displayWidth: 105,
};
});
const getLocalWeatherData = async () => {
const res = await getLocalWeather();
if (res && res.code === 0) {
@@ -68,15 +47,16 @@ onMounted(() => {
});
const getWeatherEmoji = (text) => {
if (weatherTextMap[text]) return weatherTextMap[text];
if (text.includes("晴")) return "☀️";
if (text.includes("")) return "";
if (text.includes("")) return "🌧";
if (text.includes("")) return "";
if (text.includes("")) return "🌨";
if (text.includes("雾") || text.includes("")) return "😷";
if (text.includes("")) return "🌬️";
if (text.includes("")) return "🌪";
const weather = String(text || "");
if (weatherTextMap[weather]) return weatherTextMap[weather];
if (weather.includes("")) return "";
if (weather.includes("")) return "";
if (weather.includes("")) return "🌧";
if (weather.includes("")) return "";
if (weather.includes("")) return "🌨️";
if (weather.includes("雾") || weather.includes("")) return "😷";
if (weather.includes("")) return "🌬";
if (weather.includes("沙")) return "🌪️";
return "❓";
}
@@ -150,9 +130,38 @@ const weatherTextMap = {
</script>
<style scoped>
.sprite-self-end {
align-self: flex-end;
<style lang="scss" scoped>
.home-welcome-panel {
width: 100%;
height: 92px;
padding: 12px 12px 0;
box-sizing: border-box;
background: $theme-color-50;
}
.home-welcome-header {
height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.home-welcome-title {
flex: 1;
min-width: 0;
color: $text-color-900;
font-size: 21px;
line-height: 28px;
font-weight: 900;
}
.home-welcome-weather {
flex-shrink: 0;
margin-left: 12px;
color: $text-color-900;
font-size: 22px;
line-height: 26px;
font-weight: 900;
}
</style>

View File

@@ -3,11 +3,11 @@
<swiper @change="onSwiperChange" class="swiper" circular :autoplay="autoplay" :interval="interval"
:duration="duration" :indicator-dots="false">
<swiper-item v-for="item in bannerList" :key="item.entityName">
<view class="swiper-item flex flex-col flex-items-start flex-justify-between px-10" @click="clickItem(item)">
<view class="swiper-item" @click="clickItem(item)">
<text class="text-color font-size-12 font-600">
{{ item.entityName }}
</text>
<view class="flex flex-row flex-justify-between">
<view class="notice-footer">
<text class="text-color font-size-10 font-500">
发布时间{{ item.effectiveStartTime }}
</text>
@@ -19,10 +19,6 @@
</swiper-item>
</swiper>
<yo-indicator-dot :current-index="currentIndex" :length="bannerList.length" duration="0.5" default-width="4px"
active-width="16px" dot-height="4px" shape="circle" default-color="rgba(255,255,255,0.5)"
active-color="rgba(255,255,255,1)" />
</view>
<view v-else-if="props.tipsMessage">
@@ -83,45 +79,60 @@ const clickItem = (item) => {
<style lang="scss" scoped>
.swiper {
height: 50px;
height: 48px;
margin-top: 8px;
}
.swiper-item {
display: block;
height: 46px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
height: 48px;
padding: 7px 8px;
box-sizing: border-box;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
border-radius: 12px;
border: 1px solid $theme-color-200;
background: $theme-color-100;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.96);
}
.notice-footer {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.text-color {
color: $theme-color-500;
color: $text-color-900;
}
.underline-text {
text-decoration: underline;
text-decoration-color: $theme-color-500;
text-decoration-color: $text-color-400;
color: $text-color-400;
}
.noMessage {
display: inline-flex;
display: flex;
align-items: center;
margin-top: 8px;
padding: 4px 12px;
height: 48px;
padding: 7px 8px;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
border-radius: 8px;
border: 1px solid $theme-color-200;
background: $theme-color-100;
width: fit-content;
max-width: 100%;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.96);
width: 100%;
box-sizing: border-box;
}
.noMessage-text {
line-height: 18px;
color: $text-color-900;
line-height: 16px;
white-space: normal;
overflow-wrap: break-word;
word-break: break-all;
}
</style>
</style>