对接数据

This commit is contained in:
2025-10-24 15:45:38 +08:00
parent 672a2f4c90
commit d3375a347f
138 changed files with 16904 additions and 1026 deletions

View File

@ -0,0 +1,71 @@
<template>
<div @click="goToFinanceDetail">
<div class="finance-item flex">
<div class="dot-container">
<div class="dot"></div>
</div>
<div>
<div class="title">2025-2-01</div>
<div class="content-text">Koah参与$ 500万美元种子轮融资领投Forerunner 参与投资South Park Commons </div>
</div>
</div>
<div class="diver-line"></div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {
return {}
}
},
},
data() {
return {
}
},
methods: {
goToFinanceDetail() {
this.$router.push({
path: '/finance-detail',
})
}
}
}
</script>
<style scoped lang="scss">
.diver-line {
border-top: 2px solid #E2E8F0;
}
.finance-item {
padding-top: 35px;
padding-bottom: 35px;
cursor: pointer;
&:active {
opacity: 0.8;
}
.dot-container {
padding-right: 20px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7B61FF;
margin-top: 8px;
}
}
.title {
color: #aebadee6;
font-family: 'Poppins-Medium', serif;
}
.content-text {
color: #64748B;
font-family: 'Poppins-Medium', serif;
font-size: 20px;
}
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div class="item flex items-center">
<div class="icon flex items-center justify-center">
<img :src="item.iconUrl || ''" alt="" />
</div>
<div class="text">{{ item.name || '' }}</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {
return {}
}
},
},
data() {
return {
}
},
methods: {}
}
</script>
<style scoped lang="scss">
.item {
border-radius: 12px;
padding: 14px 10px;
gap: 16px;
margin-bottom: 6px;
color: #64748B;
font-size: 18px;
font-family: 'Poppins-Medium', serif;
.icon {
width: 24px;
height: 24px;
border-radius: 4px;
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.08);
img {
width: 14px;
height: 14px;
}
}
&:hover {
background-color: #f5f5f5;
color: #7B61FF;
}
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<div class="item">
<div class="flex items-center">
<img class="icon" :src="item.iconUrl || ''" alt="" />
<div class="flex-1 flex flex-col justify-between">
<div class="title">{{ item.title || '' }}</div>
<div class="flex items-center" style="gap: 30px">
<div class="flex items-center data" style="gap: 12px">
<img alt="" src="/launches/detail/icon_thumb.png" style="width: 24px; height: 24px;" />
<div>{{ item.likeCount || 0 }}</div>
</div>
<div class="flex items-center data" style="gap: 12px">
<img alt="" src="/launches/detail/icon_star.png" style="width: 24px; height: 24px;" />
<div>{{ item.rating || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="summary">{{ item.summary || '' }}</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => ({})
}
},
data() {
return {
}
},
methods: {},
}
</script>
<style scoped lang="scss">
.item {
.icon {
width: 70px;
height: 70px;
border-radius: 6px;
margin-right: 20px;
}
.title {
font-family: 'Poppins-SemiBold', serif;
font-size: 24px;
color: #1E293B;
font-weight: 600;
}
.data {
font-family: 'Poppins-Regular', serif;
color: #64748B;
}
.summary {
font-family: 'Poppins-Regular', serif;
color: #64748B;
margin-top: 12px;
}
}
</style>

View File

@ -0,0 +1,382 @@
<template>
<div id="normal-container">
<IntegratedLayout>
<div class="content">
<div class="title-text">{{ news_detail.title || '' }}</div>
<div class="rate-box">
<Rate v-model="news_detail.rating" readonly />
<div class="flex" style="gap: 20px">
<CommentBtn :comment-count="commentCount" />
</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/icon_note.png" alt="">
<span>Introduction: </span>
</div>
<div class="item-content">
{{ news_detail.summary || '' }}
</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/icon_clock.png" alt="">
<span>Data update: </span>
</div>
<div class="item-content">{{ news_detail.publishTime || '' }}</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/like_icon.png" alt="">
<span>Like: </span>
</div>
<div class="item-content">{{ news_detail.likeCount || 0 }}</div>
</div>
<div class="tags">
<div class="tag-item" v-for="(it, index) in tagList" :key="index">{{ it }}</div>
</div>
<div class="diver"></div>
<div class="container flex">
<div class="left-content flex-1 flex flex-col">
<!--<div class="sketch flex justify-between">-->
<!-- <div class="flex items-center" style="gap: 8px">-->
<!-- <img style="width: 18px; height: 18px" src="/launches/detail/icon_fly.png" alt="" />-->
<!-- <div class="text">This is the <span style="color: #7B61FF">6</span> release of Sketch</div>-->
<!-- </div>-->
<!-- <div class="flex items-center more">-->
<!-- <div>View more</div>-->
<!-- <img src="/launches/detail/icon_arrow.png" alt="" />-->
<!-- </div>-->
<!--</div>-->
<div class="card flex-1">
<div class="article-content">
<div v-html="news_detail.content || ''"></div>
</div>
</div>
</div>
<div class="right-content">
<div class="card">
<div class="flex items-center" style="margin-top: 20px">
<img alt="" src="/launches/detail/icon_hourse.png" class="icon" />
<div class="content-title">Company Information</div>
</div>
<div class="diver-line"></div>
<ProjectItem :item="{name: 'Sketch.com', iconUrl: '/launches/web/sketch.png'}" />
<ProjectItem :item="{name: 'Github.com', iconUrl: '/launches/web/github.png'}" />
<ProjectItem :item="{name: 'Facebook.com', iconUrl: '/launches/web/facebook.png'}" />
<ProjectItem :item="{name: 'Instagram.com', iconUrl: '/launches/web/instagram.png'}" />
<ProjectItem :item="{name: 'Twitter.com', iconUrl: '/launches/web/twitter.png'}" />
<!--<div class="more flex items-center justify-between" style="margin-top: 20px">-->
<!-- <div>A total of 8 projects were released</div>-->
<!-- <img src="/launches/detail/icon_arrow.png" alt="" />-->
<!--</div>-->
</div>
<div class="card" style="margin-top: 20px">
<div class="flex items-center" style="margin-top: 20px">
<img alt="" src="/launches/detail/icon_finance.png" class="icon" />
<div class="content-title">Special Financing</div>
</div>
<div class="diver-line"></div>
<FinanceItem />
<FinanceItem />
<FinanceItem />
<!--<div class="flex justify-end" style="margin-top: 30px">-->
<!-- <div class="more flex items-center justify-between" style="width: 50%">-->
<!-- <div>View more</div>-->
<!-- <img src="/launches/detail/icon_arrow.png" alt="" />-->
<!-- </div>-->
<!--</div>-->
</div>
</div>
</div>
<div class="related">
<div class="related-title flex justify-between items-center">
<div class="title">Related Products</div>
<div @click="goToViewMore" class="more pointer">
View more<i class="el-icon-arrow-right"></i>
</div>
</div>
<div class="card">
<div class="flex-between-center list">
<RelatedTool :item="{
title: 'Figma',
summary: 'Easily create highly interactive prototypes',
likeCount: 123,
rating: 4.5
}" />
<RelatedTool :item="{
title: 'Figma',
summary: 'Easily create highly interactive prototypes',
likeCount: 123,
rating: 4.5
}" />
<RelatedTool :item="{
title: 'Figma',
summary: 'Easily create highly interactive prototypes',
likeCount: 123,
rating: 4.5
}" />
</div>
</div>
</div>
<Comment comment-type="article" :id="news_detail.id" @update:commentCount="handleCommentCountUpdate" />
</div>
</IntegratedLayout>
</div>
</template>
<script>
import CommentBtn from "@/pages/ToolDetail/components/CommentBtn.vue";
import Comment from "@/pages/ToolDetail/Comment/index.vue";
import RelatedTool from "@/pages/Launches/Detail/RelatedTool.vue";
import ProjectItem from "@/pages/Launches/Detail/ProjectItem.vue";
import FinanceItem from "@/pages/Launches/Detail/FinanceItem.vue";
export default {
components: {FinanceItem, ProjectItem, RelatedTool, CommentBtn, Comment},
data() {
return {
news_detail: {},
commentCount: 0,
news_slug: '',
}
},
methods: {
// 获取新闻详情
async getNewsDetail(newsSlug) {
const {data: res} = await this.$api.article.getArticleDetail(newsSlug);
const {code, data} = res;
if (code === 0 && data) {
this.news_detail = {...data};
}
},
stringJsonToObject(str) {
// 将json字符串转为对象捕获错误当str为空或者转对象失败时默认返回空数组
try {
return JSON.parse(str);
} catch (e) {
console.error('Error parsing JSON string:', e);
return [];
}
},
goToViewMore() {
// 返回上一页
this.$router.go(-1);
},
handleCommentCountUpdate(count) {
this.commentCount = count;
},
},
watch: {
'$route'(to, from) {
// 当路由参数发生变化时重新加载数据
if (to.query.news_slug !== from.query.news_slug) {
this.news_slug = to.query.news_slug;
if (this.news_slug) {
this.getNewsDetail(this.news_slug);
}
}
}
},
mounted() {
this.news_slug = this.$route.query.news_slug;
if (this.news_slug) {
this.getNewsDetail(this.news_slug);
}
},
computed: {
tagList() {
if (!this.news_detail.tags) {
return [];
}
return this.stringJsonToObject(this.news_detail.tags || '[]');
}
},
}
</script>
<style scoped lang="scss">
.card {
padding: 20px;
background-color: #FFFFFF;
border-radius: 12px;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.08);
}
.content {
padding-top: 100px;
padding-bottom: 100px;
.diver {
margin-top: 20px;
border-top: 4px solid #E2E8F0;
margin-bottom: 20px;
}
.title-text {
font-size: 34px;
color: #1E293B;
font-family: 'Poppins-SemiBold', serif;
font-weight: 600;
margin-top: 80px;
}
.rate-box {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.btn-wrapper {
display: flex;
align-items: center;
gap: 20px;
}
}
.terms-item {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
gap: 7px;
.item-title {
display: flex;
align-items: center;
gap: 8px;
color: #1E293B;
font-family: 'Poppins-Medium', serif;
img {
width: 24px;
height: 24px;
}
}
.item-content {
font-family: 'Poppins-Regular', serif;
color: #64748B;
}
}
.tags {
display: flex;
overflow-x: auto;
gap: 12px;
scrollbar-width: none;
-ms-overflow-style: none;
user-select: none;
flex-wrap: wrap;
width: 80%;
.tag-item {
font-family: 'Poppins-Medium', serif;
flex-shrink: 0;
padding: 4px 12px;
border-radius: 12px;
@include gradient-border($linear-gradient-start, $linear-gradient-end);
}
}
.container {
gap: 20px;
.right-content {
width: 372px;
.diver-line {
margin-top: 20px;
border-top: 2px solid #E2E8F0;
margin-bottom: 20px;
}
.content-title {
margin-left: 6px;
font-family: 'Poppins-SemiBold', serif;
font-size: 24px;
color: #1E293B;
font-weight: 600;
}
.icon {
width: 24px;
height: 24px;
}
.more {
cursor: pointer;
border-radius: 6px;
padding: 8px 15px;
border: 1px solid #e2e8f0;
gap: 16px;
font-family: 'Poppins-Regular', serif;
color: #64748B;
img {
width: 16px;
height: 16px;
}
}
}
.left-content {
.sketch {
padding: 22px;
background-color: #F5F4FF;
border-radius: 12px;
.text {
font-family: 'Poppins-Regular', serif;
color: #64748B;
font-size: 18px;
}
.more {
cursor: pointer;
border-radius: 6px;
padding: 8px 15px;
border: 1px solid #e2e8f0;
gap: 16px;
font-family: 'Poppins-Regular', serif;
color: #64748B;
img {
width: 16px;
height: 16px;
}
}
}
}
}
.related {
margin-top: 60px;
.related-title {
.title {
margin-bottom: 20px;
font-size: 30px;
color: #1E293B;
font-family: 'Poppins-SemiBold', serif;
font-weight: 600;
&::before {
content: '';
display: inline-block;
width: 6px;
height: 26px;
background: $header-backgroungd;
margin-right: 8px;
border-radius: 0 6px 6px 0;
}
}
.more {
display: block;
text-align: right;
color: $grey-color;
font-size: $mid-font-size;
font-family: 'Poppins-Regular', serif;
}
}
.list {
gap: 60px;
margin: 40px 10px 40px 10px;
}
}
}
</style>

View File

@ -0,0 +1,173 @@
<template>
<div id="normal-container">
<IntegratedLayout>
<div class="content">
<div class="title-text">{{ financeDetail.title || '' }}</div>
<div class="rate-box">
<Rate v-model="financeDetail.rating" readonly />
<div class="flex" style="gap: 20px">
<CommentBtn :comment-count="commentCount" />
</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/icon_note.png" alt="">
<span>Introduction: </span>
</div>
<div class="item-content">
{{ financeDetail.summary || '' }}
</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/icon_clock.png" alt="">
<span>Data update: </span>
</div>
<div class="item-content">{{ financeDetail.publishTime || '' }}</div>
</div>
<div class="terms-item">
<div class="item-title">
<img src="/ToolDetail/like_icon.png" alt="">
<span>Like: </span>
</div>
<div class="item-content">{{ financeDetail.likeCount || 0 }}</div>
</div>
<div class="tags">
<div class="tag-item" v-for="(it, index) in tagList" :key="index">{{ it }}</div>
</div>
<div class="diver"></div>
<div class="title">Special Financing</div>
<div class="card"></div>
</div>
</IntegratedLayout>
</div>
</template>
<script>
import CommentBtn from "@/pages/ToolDetail/components/CommentBtn.vue";
export default {
components: {CommentBtn},
data() {
return {
commentCount: 0,
financeDetail: {
title: 'The Timeline Of Sketch',
rating: 1,
summary: 'The newly upgraded Sketch of Athens has optimized the loading speed of offline maps, added an AR real-scene navigation function, and expanded the hidden scenic spots recommended by local creators, making the exploration of Athens smoother, smarter and more in-depth',
publishTime: 'Sep 03 2025',
likeCount: 100,
},
tagList: [
'Sketch',
'Athens',
'Offline Maps',
'AR',
'Navigation',
'Scenic Spots',
'Exploration',
],
}
},
methods: {},
}
</script>
<style scoped lang="scss">
.card {
padding: 50px 30px;
background-color: #FFFFFF;
border-radius: 12px;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.08);
}
.title {
margin-bottom: 20px;
font-size: 30px;
color: #1E293B;
font-family: 'Poppins-SemiBold', serif;
font-weight: 600;
&::before {
content: '';
display: inline-block;
width: 6px;
height: 26px;
background: $header-backgroungd;
margin-right: 8px;
border-radius: 0 6px 6px 0;
}
}
.content {
padding-top: 180px;
padding-bottom: 100px;
.diver {
margin-top: 20px;
border-top: 4px solid #E2E8F0;
margin-bottom: 20px;
}
.title-text {
font-size: 34px;
color: #1E293B;
font-family: 'Poppins-SemiBold', serif;
font-weight: 600;
margin-top: 80px;
}
.rate-box {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.btn-wrapper {
display: flex;
align-items: center;
gap: 20px;
}
}
.terms-item {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
gap: 7px;
.item-title {
display: flex;
align-items: center;
gap: 8px;
color: #1E293B;
font-family: 'Poppins-Medium', serif;
img {
width: 24px;
height: 24px;
}
}
.item-content {
font-family: 'Poppins-Regular', serif;
color: #64748B;
}
}
.tags {
display: flex;
overflow-x: auto;
gap: 12px;
scrollbar-width: none;
-ms-overflow-style: none;
user-select: none;
flex-wrap: wrap;
width: 80%;
.tag-item {
font-family: 'Poppins-Medium', serif;
flex-shrink: 0;
padding: 4px 12px;
border-radius: 12px;
@include gradient-border($linear-gradient-start, $linear-gradient-end);
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<script>
export default {
data() {
return {
currentPage: 1,
}
},
methods: {
}
}
</script>
<template>
<div>
<el-pagination
background
:page-size="10"
:current-page="currentPage"
layout="prev, pager, next"
:total="100">
</el-pagination>
</div>
</template>
<style scoped lang="scss">
::v-deep .el-pagination {
.btn-prev, .btn-next {
background-color: #FFFFFF !important;
border: 1px solid #E2E8F0 !important;
border-radius: 6px !important;
}
.el-pager {
.number, .more {
background-color: #FFFFFF !important;
border: 1px solid #E2E8F0 !important;
border-radius: 6px !important;
}
.active {
color: #7B61FF !important;
border: 1px solid #7B61FF !important;
background-color: #FFFFFF !important;
}
}
}
</style>

View File

@ -0,0 +1,188 @@
<template>
<div class="item-content flex justify-between" @click="handleClick">
<div class="left-content flex flex-1 items-center">
<div class="order-num">{{ sortIndex }}</div>
<div class="preview-box">
<img :src="config.coverImage || ''" alt="" />
</div>
<div class="tool-content flex flex-col justify-between">
<div class="content-top flex items-start">
<div class="icon">
<img src="/" alt="" />
</div>
<div class="flex-1">
<div class="title">{{ config.title || '' }}</div>
<div class="sub-title" style="padding-right: 30px">{{ config.summary || '' }}</div>
</div>
</div>
<div class="content-bottom flex items-center">
<img src="/launches/item/icon_clock.png" alt="" />
<div class="time-style">{{ config.publishTime || '' }}</div>
</div>
</div>
</div>
<div class="right-content flex">
<div class="flex items-center">
<img :src="hovered ? '/launches/item/icon_thumb_grey.png' : '/launches/item/icon_thumb.png'" alt="" />
<div :class="{ 'hover-text': hovered }">{{ config.likeCount || 0 }}</div>
</div>
<div class="flex items-center">
<img :src="hovered ? '/launches/item/icon_star_grey.png' : '/launches/item/icon_star.png'" alt="" />
<div :class="{ 'hover-text': hovered }">{{ config.rating || 0 }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hovered: false
}
},
props: {
sortIndex: {
type: Number,
default: 1,
},
config: {
type: Object,
default: () => {
return {}
},
},
},
methods: {
handleMouseEnter() {
this.hovered = true;
},
handleMouseLeave() {
this.hovered = false;
},
// 跳转详情页
handleClick() {
if (!this.config.slug) {
return false;
}
this.articleClick(this.config.id);
this.$router.push({
path: '/launches/detail',
query: {
news_slug: this.config.slug || '',
}
});
},
// 记录文章点击数
async articleClick(id) {
if (id) {
await this.$api.article.recordArticleClick(id);
}
}
},
mounted() {
const itemContent = this.$el;
itemContent.addEventListener('mouseenter', this.handleMouseEnter);
itemContent.addEventListener('mouseleave', this.handleMouseLeave);
},
beforeDestroy() {
const itemContent = this.$el;
itemContent.removeEventListener('mouseenter', this.handleMouseEnter);
itemContent.removeEventListener('mouseleave', this.handleMouseLeave);
}
}
</script>
<style scoped lang="scss">
.item-content {
border-radius: 12px;
padding: 30px 20px;
transition: background-color 0.3s ease;
&:hover {
background-color: #F5F4FF;
.order-num {
background: #fff;
}
}
.left-content {
gap: 30px;
.order-num {
width: 36px;
height: 36px;
border-radius: 12px;
background: #F5F4FF;
margin-right: 10px;
text-align: center;
line-height: 36px;
font-size: 18px;
font-family: 'Poppins-Regular', serif;
transition: background-color 0.3s ease;
}
.preview-box {
width: 228px;
height: 133px;
border-radius: 6px;
background-color: #FFFFFF;
img {
width: 100%;
height: 100%;
}
}
.tool-content {
height: 100%;
.content-top {
.icon {
width: 46px;
height: 46px;
margin-right: 10px;
img {
width: 100%;
height: 100%;
}
}
.title {
font-size: 20px;
font-family: 'Poppins-SemiBold', serif;
font-weight: 600;
line-height: 24px;
color: #3A4A65;
}
.sub-title {
color: #64748B;
font-family: 'Poppins-Regular', serif;
margin-top: 10px;
}
}
.content-bottom {
gap: 12px;
img {
width: 24px;
height: 24px;
}
.time-style {
color: #64748B;
font-family: 'Poppins-Regular', serif;
}
}
}
}
.right-content {
padding-right: 23px;
gap: 33px;
font-family: 'Poppins-Regular', serif;
color: #E2E8F0;
height: 24px;
img {
width: 24px;
height: 24px;
margin-right: 12px;
}
.hover-text {
color: #64748B;
}
}
}
</style>

View File

@ -0,0 +1,314 @@
<template>
<div class="date-picker-core">
<!-- 日期// 选择区 -->
<div class="picker-container">
<!-- 日模式 -->
<div v-if="mode === 'Daily'" class="daily-picker">
<HorizontalDateList>
<button
v-for="day in dailyDays"
:key="day.dateStr"
:class="{ 'selected': day.dateStr === selectedDate }"
@click="handleSelect(day.dateStr)"
>{{ day.day }}</button>
</HorizontalDateList>
</div>
<!-- 周模式 -->
<div v-else-if="mode === 'Weekly'" class="weekly-picker">
<HorizontalDateList>
<button
v-for="week in weeklyRanges"
:key="week.start"
:class="{ 'selected': week.start === selectedDate[0] && week.end === selectedDate[1] }"
@click="handleSelect([week.start, week.end])"
>{{ week.label }}</button>
</HorizontalDateList>
</div>
<!-- 月模式 -->
<div v-else class="monthly-picker">
<HorizontalDateList>
<button
style="margin: 0 15px"
v-for="(monthAbbr, index) in monthAbbrs"
:key="index"
:class="{ 'selected': (index + 1) === selectedMonth }"
@click="handleMonthSelect(index + 1)"
>{{ monthAbbr }}</button>
</HorizontalDateList>
</div>
</div>
</div>
</template>
<script>
import HorizontalDateList from '@/components/HorizontalDateList.vue';
export default {
name: 'OptionDates',
components: {
HorizontalDateList
},
props: {
// 当前年份,由外部传入
year: {
type: Number,
required: true
},
// 当前模式,由外部传入 (Daily, Weekly, Monthly)
mode: {
type: String,
required: true,
validator: (val) => ['Daily', 'Weekly', 'Monthly'].includes(val)
},
// 日模式下的当前月份,由外部传入
month: {
type: Number,
default: () => new Date().getMonth() + 1,
validator: (val) => val >= 1 && val <= 12
}
},
data() {
return {
selectedDate: this.mode === 'Daily' ? '' : ['', ''], // 选中的日期/日期范围
selectedMonth: this.month // 月模式下选中的月份
};
},
computed: {
// 日模式:生成"当前年月"的所有日期
dailyDays() {
const daysInMonth = this.getDaysInMonth(this.year, this.month);
return Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
const date = new Date(this.year, this.month - 1, day);
return {
day,
dateStr: this.formatDate(date)
};
});
},
// 周模式:生成"当前年份"的所有完整周区间
weeklyRanges() {
return this.getWeekRanges(this.year);
},
// 月模式12个月份的英文简写
monthAbbrs() {
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
}
},
created() {
// 组件创建时自动选中当前日期或周
this.autoSelectCurrentDate();
},
methods: {
// 自动选中当前日期或周
autoSelectCurrentDate(shouldEmit = true) {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const currentDateStr = this.formatDate(now);
// 只有当年份和月份匹配当前日期时才自动选中
if (this.year === currentYear) {
if (this.mode === 'Daily' && this.month === currentMonth) {
this.selectedDate = currentDateStr;
if (shouldEmit) {
this.$emit('select', currentDateStr);
}
} else if (this.mode === 'Weekly') {
// 找到当前日期所在的周
const currentWeek = this.getWeekForDate(now);
if (currentWeek) {
this.selectedDate = [currentWeek.start, currentWeek.end];
if (shouldEmit) {
this.$emit('select', [currentWeek.start, currentWeek.end]);
}
}
} else if (this.mode === 'Monthly') {
// 月模式下自动选中当前月并导出日期范围
this.selectedMonth = currentMonth;
const [start, end] = this.getMonthRange(this.year, currentMonth);
if (shouldEmit) {
this.$emit('select', [start, end]);
}
}
}
},
// 获取指定日期所在的周
getWeekForDate(date) {
const weeks = this.getWeekRanges(this.year);
const dateStr = this.formatDate(date);
for (const week of weeks) {
// 检查日期是否在该周范围内
if (dateStr >= week.start && dateStr <= week.end) {
return week;
}
}
return null;
},
// 处理日/周模式的选择
handleSelect(value) {
this.selectedDate = value;
this.$emit('select', value);
},
// 处理月模式的选择
handleMonthSelect(month) {
this.selectedMonth = month;
const [start, end] = this.getMonthRange(this.year, month);
this.$emit('select', [start, end]);
},
// 辅助获取某月的天数month1-12
getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
},
// 辅助:格式化日期为 yyyy-mm-dd
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
// 辅助获取某月的首尾日期month1-12
getMonthRange(year, month) {
const start = new Date(year, month - 1, 1);
const end = new Date(year, month, 0);
return [this.formatDate(start), this.formatDate(end)];
},
// 辅助:获取某年的所有完整周区间(假设周从周日开始,到周六结束)
getWeekRanges(year) {
const ranges = [];
let currentDate = new Date(year, 0, 1); // 当年1月1日
// 找到第一个周日(作为周起始)
while (currentDate.getDay() !== 0) {
currentDate.setDate(currentDate.getDate() + 1);
}
// 生成所有"结束日期在当年"的完整周
while (currentDate.getFullYear() === year) {
const start = new Date(currentDate);
const end = new Date(currentDate);
end.setDate(end.getDate() + 6); // 周日到周六共7天
if (end.getFullYear() === year) { // 确保周结束在当年
ranges.push({
label: `${this.monthAbbrs[start.getMonth()]} ${start.getDate()}-${end.getDate()}`,
start: this.formatDate(start),
end: this.formatDate(end)
});
}
currentDate.setDate(currentDate.getDate() + 7); // 下一周
}
return ranges;
},
// 重置选择状态
resetSelection() {
if (this.mode === 'Daily') {
this.selectedDate = '';
} else if (this.mode === 'Weekly') {
this.selectedDate = ['', ''];
} else {
this.selectedMonth = this.month;
}
}
},
watch: {
// 监听年份变化,重置选择
year() {
this.resetSelection();
this.autoSelectCurrentDate();
},
mode: {
handler(newMode, oldMode) {
if (newMode !== oldMode) {
this.resetSelection();
// 切换模式时不立即触发 emit让组件自行处理
this.autoSelectCurrentDate(false);
// 通知子组件重新计算滚动状态
this.$nextTick(() => {
this.$children.forEach(child => {
if (child.$options.name === 'HorizontalDateList') {
child.updateScrollState();
}
});
});
}
},
immediate: true
},
month() {
if (this.mode === 'Daily') {
this.selectedDate = '';
}
this.autoSelectCurrentDate();
}
},
};
</script>
<style scoped>
.date-picker-core {
padding: 10px;
}
.picker-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
background-color: #ffffff;
}
.daily-picker,
.weekly-picker,
.monthly-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.daily-picker button,
.weekly-picker button,
.monthly-picker button {
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
&:active {
opacity: 0.8;
}
}
.daily-picker button:hover,
.weekly-picker button:hover,
.monthly-picker button:hover {
border-color: #9ca3af;
}
.daily-picker button.selected,
.weekly-picker button.selected,
.monthly-picker button.selected {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<div class="switch-box flex-between-center">
<div class="item-text" :class="value === 'Daily' && 'active'" @click="handleClick('Daily')">Daily</div>
<div class="diver"></div>
<div class="item-text" :class="value === 'Weekly' && 'active'" @click="handleClick('Weekly')">Weekly</div>
<div class="diver"></div>
<div class="item-text" :class="value === 'Monthly' && 'active'" @click="handleClick('Monthly')">Monthly</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: String,
default: 'Daily'
}
},
data() {
return {}
},
methods: {
handleClick(type) {
this.$emit('input', type);
}
}
}
</script>
<style scoped lang="scss">
.switch-box {
padding: 22px 40px;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 10px 30px 0 #0000000d;
.diver {
width: 1px;
height: 36px;
margin-left: 70px;
margin-right: 70px;
border-left-width: 1px;
border-left-style: solid;
border-left-color: #E2E8F0;
}
.item-text {
font-size: 24px;
font-family: 'Poppins-Regular', serif;
color: #3A4A65;
&:active {
opacity: 0.8;
}
}
.active {
color: #7B61FF;
font-weight: 900 !important;
font-family: 'Poppins-Bold', serif;
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="flex-center box">
<div class="btn" @click="prevYear">
<img :src="prevIcon" alt="Previous Year" />
</div>
<div style="width: 60px;text-align: center">{{ monthName }}</div>
<div class="btn" @click="nextYear">
<img :src="nextIcon" :alt="isNextDisabled ? 'Next Year Disabled' : 'Next Year'" />
</div>
</div>
</template>
<script>
import IconPrev from '@/static/launches/icon_prev.png';
import IconNext from '@/static/launches/icon_next.png';
import IconNextDisabled from '@/static/launches/icon_next_disabled.png';
import IconPrevDisabled from '@/static/launches/icon_prev_disabled.png';
export default {
data() {
return {
}
},
props: {
value: {
type: Number,
default: () => new Date().getMonth() + 1
}
},
methods: {
prevYear() {
if (this.value > 1) {
this.$emit('input', this.value - 1);
}
},
nextYear() {
if (this.value < 12) {
this.$emit('input', this.value + 1);
}
}
},
computed: {
monthName() {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[this.value - 1];
},
prevIcon() {
return this.value > 1 ? IconPrev : IconPrevDisabled;
},
nextIcon() {
return this.value < 12 ? IconNext : IconNextDisabled;
},
isNextDisabled() {
return this.value >= 12;
}
},
}
</script>
<style scoped lang="scss">
.box {
gap: 16px;
color: #3A4A65;
font-size: 24px;
font-family: 'Poppins-SemiBold', serif;
.btn {
width: 48px;
height: 48px;
cursor: pointer;
background-color: #FFFFFF;
border-radius: 4px;
&:active {
opacity: 0.8;
}
img {
width: 100%;
height: 100%;
}
}
}
</style>

View File

@ -0,0 +1,47 @@
<script>
export default {
props: {
value: {
type: String,
default: 'popular'
}
},
methods: {
handleClick(type) {
this.$emit('input', type);
}
}
}
</script>
<template>
<div class="switch-sort-box flex-center">
<div class="btn-item" @click="handleClick('popular')">
<img :src="value === 'popular' ? '/launches/icon_popular_checked.png' : '/launches/icon_popular.png'" alt="" />
</div>
<div class="btn-item" @click="handleClick('newest')">
<img :src="value === 'newest' ? '/launches/icon_newest_checked.png' : '/launches/icon_newest.png'" alt="" />
</div>
</div>
</template>
<style scoped lang="scss">
.switch-sort-box {
width: 294px;
height: 52px;
background-color: #FFFFFF;
border-radius: 12px;
.btn-item {
width: 147px;
height: 52px;
cursor: pointer;
&:active {
opacity: 0.8;
}
img {
width: 100%;
height: 100%;
}
}
}
</style>

View File

@ -0,0 +1,78 @@
<script>
import IconPrev from '@/static/launches/icon_prev.png';
import IconNext from '@/static/launches/icon_next.png';
import IconNextDisabled from '@/static/launches/icon_next_disabled.png';
export default {
props: {
value: {
type: Number,
default: () => new Date().getFullYear()
}
},
data() {
return {}
},
computed: {
year() {
return this.value;
},
prevIcon() {
return IconPrev;
},
nextIcon() {
return this.year === new Date().getFullYear() ? IconNextDisabled : IconNext;
},
isNextDisabled() {
return this.year === new Date().getFullYear();
}
},
methods: {
prevYear() {
this.$emit('input', this.year - 1);
},
nextYear() {
if (!this.isNextDisabled) {
this.$emit('input', this.year + 1);
}
}
}
}
</script>
<template>
<div class="flex-center box">
<div class="btn" @click="prevYear">
<img :src="prevIcon" alt="Previous Year" />
</div>
<div style="width: 60px;text-align: center">{{ year }}</div>
<div class="btn" @click="nextYear">
<img :src="nextIcon" :alt="isNextDisabled ? 'Next Year Disabled' : 'Next Year'" />
</div>
</div>
</template>
<style scoped lang="scss">
.box {
gap: 16px;
color: #3A4A65;
font-size: 24px;
font-family: 'Poppins-SemiBold', serif;
.btn {
width: 48px;
height: 48px;
cursor: pointer;
background-color: #FFFFFF;
border-radius: 4px;
&:active {
opacity: 0.8;
}
img {
width: 100%;
height: 100%;
}
}
}
</style>

220
pages/Launches/index.vue Normal file
View File

@ -0,0 +1,220 @@
<template>
<div id="normal-container" class="launches-content" v-loading.fullscreen.lock="fullscreenLoading">
<IntegratedLayout>
<div class="content">
<div class="launches-header">
<h1 class="launches-title-text">
AI Launches
</h1>
<p class="launches-subtitle-text">A daily curated selection of the newest AI applications and products. Explore the most talked-about innovations, trending technologies, and hot new releases shaping the AI landscape. Stay ahead by discovering whats capturing attention in the AI community.</p>
</div>
<div class="flex justify-center">
<SwitchDate v-model="currentMode" />
</div>
<div class="list-header flex-between-center">
<SwitchSort v-model="sortType" />
<div class="flex items-center" style="gap: 60px">
<SwitchMonth v-show="currentMode === 'Daily'" v-model="currentMonth" />
<SwitchYear v-model="currentYear" />
</div>
</div>
<div class="card list-container">
<OptionDates :year="currentYear" :mode="currentMode" :month="currentMonth" @select="handleDateSelect" />
<div class="diver"></div>
<div class="list">
<ListItem v-for="(it, i) in articleList" :key="it.id" :config="it" :sort-index="i + 1" />
</div>
</div>
<div class="flex-bottom-right">
<Pagination :current-page="currentPage" :total-pages="totalPages" @page-change="handlePageChange" />
</div>
</div>
</IntegratedLayout>
</div>
</template>
<script>
import SwitchDate from "@/pages/Launches/components/SwitchDate.vue";
import SwitchSort from "@/pages/Launches/components/SwitchSort.vue";
import SwitchYear from "@/pages/Launches/components/SwitchYear.vue";
import SwitchMonth from "@/pages/Launches/components/SwitchMonth.vue";
import ListItem from "@/pages/Launches/components/ListItem.vue";
import OptionDates from "@/pages/Launches/components/OptionDates.vue";
export default {
components: {
SwitchDate,
SwitchSort,
SwitchYear,
ListItem,
OptionDates,
SwitchMonth,
},
data() {
return {
lastSelectedValue: null,
currentPage: 1,
totalPages: 1,
pageSize: 10,
total: 0,
currentYear: new Date().getFullYear(),
currentMode: 'Daily',
currentMonth: new Date().getMonth() + 1,
articleList: [],
sortType: 'popular',
fullscreenLoading: false,
}
},
watch: {
total() {
this.calculateTotalPages();
},
pageSize() {
this.calculateTotalPages();
},
sortType(newVal, oldVal) {
if (oldVal !== null) { // 避免初始化时触发
let startTime = '';
let endTime = '';
if (this.lastSelectedValue) {
try {
const selectedValue = JSON.parse(this.lastSelectedValue);
if (selectedValue instanceof Array) {
startTime = selectedValue[0] + ' 00:00:00';
endTime = selectedValue[1] + ' 23:59:59';
} else {
startTime = selectedValue + ' 00:00:00';
endTime = selectedValue + ' 23:59:59';
}
} catch (e) {
console.error('解析时间参数失败', e);
}
}
// 重新获取文章列表,传入新的排序类型
this.getArticleListData(this.currentPage, this.pageSize, startTime, endTime, newVal);
}
},
},
methods: {
calculateTotalPages() {
// 当 total 为 0 时 totalPages 为 1
// 否则向上取整计算总页数
this.totalPages = this.total === 0 ? 1 : Math.ceil(this.total / this.pageSize);
},
handleDateSelect(selectedValue) {
const stringValue = JSON.stringify(selectedValue);
if (this.lastSelectedValue === stringValue) {
return;
}
this.lastSelectedValue = stringValue;
let startTime = '';
let endTime = '';
if (selectedValue instanceof Array) {
startTime = selectedValue[0] + ' 00:00:00';
endTime = selectedValue[1] + ' 23:59:59';
} else {
startTime = selectedValue + ' 00:00:00';
endTime = selectedValue + ' 23:59:59';
}
this.getArticleListData(this.currentPage, this.pageSize, startTime, endTime, this.sortType);
},
// 获取文章列表
async getArticleListData(page = 1, limit = 10, startTime, endTime, sortType = 'popular') {
const params = {page, limit, startTime, endTime, articleType: 'launches'};
if (sortType === 'popular') {
params.isHot = 1;
} else {
params.sortField = 'publish_time';
params.sortOrder = 'desc';
}
this.fullscreenLoading = true;
const {data: res} = await this.$api.article.getArticleList(params);
const {code, data} = res;
if (code === 0 && data.list) {
this.articleList = data.list;
this.total = data.total;
this.calculateTotalPages();
} else {
this.articleList = [];
this.total = 0;
this.calculateTotalPages();
}
this.fullscreenLoading = false;
},
handlePageChange(pageNumber) {
this.currentPage = pageNumber;
// 从 lastSelectedValue 中提取时间参数
let startTime = '';
let endTime = '';
if (this.lastSelectedValue) {
try {
const selectedValue = JSON.parse(this.lastSelectedValue);
if (selectedValue instanceof Array) {
startTime = selectedValue[0] + ' 00:00:00';
endTime = selectedValue[1] + ' 23:59:59';
} else {
startTime = selectedValue + ' 00:00:00';
endTime = selectedValue + ' 23:59:59';
}
} catch (e) {
console.error('解析时间参数失败', e);
}
}
this.getArticleListData(pageNumber, this.pageSize, startTime, endTime, this.sortType);
},
},
}
</script>
<style scoped lang="scss">
.card {
padding: 60px 30px;
background-color: #FFFFFF;
border-radius: 12px;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.08);
}
.launches-content {
flex: 1;
overflow-y: auto;
position: relative;
.content {
padding-top: 190px;
padding-bottom: 100px;
.launches-header {
margin-bottom: 114px;
.launches-title-text {
margin: 0;
font-size: 40px;
font-weight: bold;
font-family: 'Poppins-Bold', serif;
}
.launches-subtitle-text {
font-family: 'Poppins-Medium', serif;
color: #64748B;
margin-top: 10px;
}
}
.list-header {
margin-top: 56px;
}
.list-container {
margin-top: 60px;
margin-bottom: 50px;
.diver {
height: 1px;
border-top-color: #E2E8F0;
border-top-style: solid;
border-top-width: 2px;
margin-top: 40px;
margin-bottom: 40px;
}
.list {
gap: 30px;
display: flex;
flex-direction: column;
}
}
}
}
</style>