This commit is contained in:
2026-08-04 13:21:17 +08:00
commit cfdaceff26
90 changed files with 7939 additions and 0 deletions

78
src/views/AboutView.vue Normal file
View File

@@ -0,0 +1,78 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getAboutContent } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const sections = ref([])
const activeSectionKey = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('about')
const activeSection = computed(() => sections.value.find((item) => item.name === activeSectionKey.value) || sections.value[0] || {})
function normalizeSections(list) {
return (list || []).map((item) => ({
...item,
name: item.name || item.title,
}))
}
onMounted(async () => {
loading.value = true
try {
const res = await getAboutContent()
sections.value = normalizeSections(res || [])
activeSectionKey.value = sections.value[0]?.name || ''
} finally {
loading.value = false
}
})
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>关于我们</em>
</div>
<p class="info-kicker">ABOUT US</p>
<h1>关于我们</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>栏目导航</h2>
<div class="about-menu-list">
<button
v-for="item in sections"
:key="item.id"
type="button"
:class="['about-menu-button', { active: activeSection.name === item.name }]"
@click="activeSectionKey = item.name"
>
{{ item.title }}
</button>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head">
<h2>{{ activeSection.title }}</h2>
</div>
<div v-if="loading" class="info-card">
<p>正在加载关于我们内容...</p>
</div>
<div v-else class="rich-content about-rich-content" v-html="activeSection.content || '<p>暂无内容</p>'"></div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getCases } from '../utils/api'
const route = useRoute()
const article = ref(null)
const casesList = ref([])
const articleIndex = computed(() => {
return casesList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? casesList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < casesList.value.length - 1 ? casesList.value[index + 1] : null
})
async function loadCaseDetail() {
const res = await getCases()
casesList.value = res.list || []
article.value = casesList.value.find((item) => String(item.id) === String(route.params.slug)) || casesList.value[0] || null
}
watch(
() => route.params.slug,
async () => {
await loadCaseDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="{ name: 'cases' }">成功案例</RouterLink>
<span>/</span>
<em>{{ article.serviceCategory?.name || article.industryCategory?.name || '案例详情' }}</em>
</div>
<!-- <p class="article-category">{{ article.serviceCategory?.name || article.industryCategory?.name || '成功案例' }}</p>-->
<h1>{{ article.client_name }}</h1>
<div class="article-meta">
<span>{{ article.industry }}</span>
<span>{{ article.title || article.published_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div class="article-cover">
<img :src="article.cover" :alt="article.client_name" />
</div>
<div class="article-summary">
<p>{{ article.service_content }}</p>
</div>
<div class="case-metrics article-case-metrics">
<span>{{ article.industry }}</span>
<span v-if="article.industryCategory?.name">{{ article.industryCategory.name }}</span>
<span v-if="article.serviceCategory?.name">{{ article.serviceCategory.name }}</span>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无案例详情</p>'">
</div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="{ name: 'cases' }">返回案例列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关案例</h3>
<RouterLink
v-for="item in casesList"
:key="item.id"
:to="{ name: 'case-detail', params: { slug: item.id } }"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img :src="item.cover" :alt="item.client_name" />
<div>
<strong>{{ item.client_name }}</strong>
<span>{{ item.serviceCategory?.name || item.industry }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="{ name: 'case-detail', params: { slug: prevArticle.id } }"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="{ name: 'case-detail', params: { slug: nextArticle.id } }"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载案例详情...</p></article>
</div>
</section>
</main>
</template>

165
src/views/CasesView.vue Normal file
View File

@@ -0,0 +1,165 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import caseBg from '../assets/cgal.png'
import caseBgActive from '../assets/cgal-active.png'
import { getCases } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const industryTabs = ref([])
const serviceTabs = ref([])
const casesList = ref([])
const activeIndustryId = ref('')
const activeServiceId = ref('')
const pageDescription = usePageDescription('cases')
function isActiveIndustry(id) {
return String(activeIndustryId.value || '') === String(id || '')
}
function isActiveService(id) {
return String(activeServiceId.value || '') === String(id || '')
}
function syncActiveState() {
activeIndustryId.value = String(route.query.industry_category_id || '')
activeServiceId.value = String(route.query.service_category_id || '')
}
async function loadCases() {
const res = await getCases({
industry_category_id: activeIndustryId.value || undefined,
service_category_id: activeServiceId.value || undefined,
})
industryTabs.value = res.categories?.industry || []
serviceTabs.value = res.categories?.service || []
casesList.value = res.list || []
}
async function changeIndustry(id) {
const nextIndustryId = activeIndustryId.value === String(id || '') ? '' : String(id || '')
await router.replace({
path: '/cases',
query: {
...(nextIndustryId ? { industry_category_id: nextIndustryId } : {}),
...(activeServiceId.value ? { service_category_id: activeServiceId.value } : {}),
},
})
}
async function changeService(id) {
const nextServiceId = activeServiceId.value === String(id || '') ? '' : String(id || '')
await router.replace({
path: '/cases',
query: {
...(activeIndustryId.value ? { industry_category_id: activeIndustryId.value } : {}),
...(nextServiceId ? { service_category_id: nextServiceId } : {}),
},
})
}
onMounted(async () => {
syncActiveState()
await loadCases()
})
watch(
() => [route.query.industry_category_id, route.query.service_category_id],
async () => {
syncActiveState()
await loadCases()
},
)
</script>
<template>
<main class="cases-page">
<section class="cases-hero">
<div class="container cases-hero-inner">
<div class="cases-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>成功案例</em>
</div>
<p class="cases-kicker">SUCCESS CASES</p>
<h1>成功案例</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="cases-page-section">
<div class="container">
<div class="tab-row cases-page-tabs">
<button
type="button"
:class="['tab-link tab-button', { active: !activeIndustryId }]"
@click="changeIndustry('')"
>
全部行业
</button>
<button
v-for="tab in industryTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: isActiveIndustry(tab.id) }]"
@click="changeIndustry(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div class="tab-row compact cases-page-tabs">
<button
type="button"
:class="['tab-link tab-button', { active: !activeServiceId }]"
@click="changeService('')"
>
全部服务
</button>
<button
v-for="tab in serviceTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: isActiveService(tab.id) }]"
@click="changeService(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div v-if="!casesList.length" class="info-card">
<p>暂无成功案例数据</p>
</div>
<div v-else class="cases-page-grid">
<article
v-for="item in casesList"
:key="item.id"
class="case-card case-card-large"
:style="{
backgroundImage: `url(${caseBg})`,
'--case-bg-active': `url(${caseBgActive})`,
}"
>
<p class="case-category">{{ item.serviceCategory?.name || item.industryCategory?.name || '成功案例' }}</p>
<h3>{{ item.client_name }}</h3>
<p class="case-subtitle">{{ item.title || item.industry }}</p>
<p class="case-summary">{{ item.service_content }}</p>
<div class="case-metrics">
<span>{{ item.industry }}</span>
<span v-if="item.serviceCategory?.name">{{ item.serviceCategory.name }}</span>
</div>
<RouterLink
:to="{ name: 'case-detail', params: { slug: item.id } }"
class="ghost-button"
>
了解详情
</RouterLink>
</article>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { reactive, ref } from 'vue'
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus'
import axios from 'axios'
import { usePageDescription } from '../utils/pageSettings'
const consultingOptions = [
'采购数字化规划',
'制度流程梳理',
'平台建设与落地',
'数据治理与分析',
'培训与运营支持',
]
const form = reactive({
company: '',
contact: '',
phone: '',
landline: '',
option: consultingOptions[0],
message: '',
})
const submitting = ref(false)
const pageDescription = usePageDescription('consultingRequest')
async function submitRequest() {
if (!form.company || !form.contact || !form.phone) {
ElMessage.warning('请先完善单位名称、联系人和手机号')
return
}
submitting.value = true
try {
await axios.post(`${import.meta.env.VITE_API_BASE_URL}/consulting/request`, {
company: form.company,
contact: form.contact,
phone: form.phone,
landline: form.landline,
option: form.option,
message: form.message,
source_path: window.location.pathname,
source_title: '咨询需求',
})
ElMessage.success('提交成功')
form.company = ''
form.contact = ''
form.phone = ''
form.landline = ''
form.option = consultingOptions[0]
form.message = ''
} finally {
submitting.value = false
}
}
</script>
<template>
<main class="consulting-page">
<section class="consulting-hero">
<div class="container consulting-hero-inner">
<div class="consulting-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>咨询需求</em>
</div>
<div class="consulting-hero-copy">
<p class="consulting-kicker">CONSULTING REQUEST</p>
<h1>咨询需求</h1>
<p>{{ pageDescription }}</p>
</div>
</div>
</section>
<section class="consulting-section">
<div class="container consulting-request-layout">
<section class="consulting-card form-card">
<div class="form-card-head">
<div>
<h2>提交咨询需求</h2>
<p>{{ pageDescription }}</p>
</div>
<span class="form-badge">1对1响应</span>
</div>
<ElForm class="consulting-form" :model="form" label-position="top">
<div class="consulting-form-grid">
<ElFormItem label="单位名称" class="consulting-form-item">
<ElInput v-model="form.company" placeholder="请输入单位名称" />
</ElFormItem>
<ElFormItem label="联系人" class="consulting-form-item">
<ElInput v-model="form.contact" placeholder="请输入联系人姓名" />
</ElFormItem>
<ElFormItem label="手机号" class="consulting-form-item">
<ElInput v-model="form.phone" placeholder="请输入手机号" />
</ElFormItem>
<ElFormItem label="固定电话" class="consulting-form-item">
<ElInput v-model="form.landline" placeholder="请输入固定电话(选填)" />
</ElFormItem>
<ElFormItem label="咨询方向" class="consulting-form-item">
<ElSelect v-model="form.option" placeholder="请选择咨询方向">
<ElOption
v-for="item in consultingOptions"
:key="item"
:label="item"
:value="item"
/>
</ElSelect>
</ElFormItem>
</div>
<ElFormItem label="需求描述" class="consulting-form-item consulting-textarea-item">
<ElInput
v-model="form.message"
type="textarea"
:rows="6"
placeholder="请简要描述您的业务场景、当前问题或期望达成的目标"
/>
</ElFormItem>
<div class="form-actions">
<ElButton type="danger" class="consulting-submit" size="large" :loading="submitting" @click="submitRequest">提交咨询</ElButton>
<p>提交后将进入后台咨询管理并同步统计</p>
</div>
</ElForm>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,104 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getServices } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const services = ref([])
const activeServiceId = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('consulting')
const currentService = computed(() => (
services.value.find((item) => String(item.id) === String(activeServiceId.value))
|| services.value[0]
|| null
))
async function loadServicesList() {
loading.value = true
try {
services.value = await getServices({
type: 'consulting',
})
syncActiveService()
} finally {
loading.value = false
}
}
function syncActiveService() {
if (!services.value.length) {
activeServiceId.value = ''
return
}
const matchedService = services.value.find((item) => String(item.id) === String(activeServiceId.value))
activeServiceId.value = matchedService?.id || services.value[0]?.id || ''
}
function isActiveService(id) {
return String(activeServiceId.value || '') === String(id || '')
}
function changeService(id) {
activeServiceId.value = String(id || '')
}
onMounted(async () => {
await loadServicesList()
})
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>数智咨询</em>
</div>
<p class="info-kicker">BUSINESS CONSULTING</p>
<h1>数智咨询</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>咨询列表</h2>
<div v-if="services.length" class="about-menu-list">
<button
v-for="item in services"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveService(item.id) }]"
@click="changeService(item.id)"
>
{{ item.title || item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载数智咨询数据...' : '暂无数智咨询数据' }}</p>
</div>
</aside>
<article v-if="currentService" class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ currentService.title }}</h2>
<p v-if="currentService.name">{{ currentService.name }}</p>
</div>
</div>
<div v-if="currentService.cover" class="info-cover-image">
<img :src="currentService.cover" :alt="currentService.name" />
</div>
<div class="rich-content" v-html="currentService.content || '<p>暂无详细内容</p>'"></div>
</article>
</section>
</div>
</section>
</main>
</template>

65
src/views/ContactView.vue Normal file
View File

@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue'
import { useContactInfo } from '../utils/siteConfig'
const contactInfo = useContactInfo()
const baiduMapUrl = computed(() => `https://map.baidu.com/search/${encodeURIComponent(contactInfo.value.address)}`)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>联系我们</em>
</div>
<p class="info-kicker">CONTACT US</p>
<h1>联系我们</h1>
<p>
如您希望了解产品方案咨询服务或合作方式欢迎通过电话邮件或现场沟通与我们联系我们将尽快安排专人与您对接
</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout two-col">
<article class="info-card">
<h2>联系信息</h2>
<dl class="contact-detail-list">
<div>
<dt>联系电话</dt>
<dd>{{ contactInfo.phone }}</dd>
</div>
<div>
<dt>官方邮箱</dt>
<dd>{{ contactInfo.email }}</dd>
</div>
<div>
<dt>公司地址</dt>
<dd>
<a class="map-link" :href="baiduMapUrl" target="_blank" rel="noreferrer">
{{ contactInfo.address }}
</a>
</dd>
</div>
<div>
<dt>服务时间</dt>
<dd>工作日 09:00 - 18:00</dd>
</div>
</dl>
</article>
<article class="info-card">
<h2>沟通建议</h2>
<ul class="contact-tips">
<li>如需业务咨询可优先通过业务咨询页面填写需求</li>
<li>如涉及项目合作或平台建设建议提前准备单位背景与核心诉求</li>
<li>如需线下交流可提前电话预约我们将安排对应顾问接待</li>
</ul>
</article>
</div>
</section>
</main>
</template>

520
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,520 @@
<script setup>
import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'
import {RouterLink, useRouter} from 'vue-router'
import businessBg from '../assets/hxywjz.png'
import advantageIcon0 from '../assets/qyys_0.png'
import advantageIcon1 from '../assets/qyys_1.png'
import advantageIcon2 from '../assets/qyys_2.png'
import advantageIcon3 from '../assets/qyys_3.png'
import caseBg from '../assets/cgal.png'
import caseBgActive from '../assets/cgal-active.png'
import emptyContentImage from '../assets/暂无内容.png'
import {getArticleCategories, getArticles, getBanners, getSiteHome, getServices} from '../utils/api'
const defaultAdvantages = [
{
icon: advantageIcon0,
title: '官方独家合作',
description:
'广西唯一官方合作伙伴,全权负责广西全区 134 个区划平台运营、推广与培训。',
},
{
icon: advantageIcon1,
title: '数智产品矩阵完备',
description:
'自主研发汇易采、AI 智能体、调研 / 代理 / 履约验收等 SAAS 平台,覆盖采购全流程,实现链路闭环。',
},
{
icon: advantageIcon2,
title: '资质与技术实力雄厚',
description:
'持有七证一牌、多项软著、管理体系认证与科技成果,团队持高级工程师等证书,技术与合规双保障。',
},
{
icon: advantageIcon3,
title: '本土服务与案例领先',
description:
'落地高校、医院、福彩等标杆案例7×24 小时本地化服务,经验成熟。',
},
]
const defaultAdvantageStats = [
{name: '软件证书', data: '26', unit: '项'},
{name: '服务单位', data: '2', unit: '万'},
{name: '供应商', data: '19', unit: '万'},
]
const homeData = ref({cases: [], articles: [], advantages: [], advantage_stats: []})
const articleCategories = ref([])
const activeNewsCategoryId = ref('')
const newsList = ref([])
const banners = ref([])
const businessSlides = ref([])
const heroCarouselRef = useTemplateRef('heroCarouselRef')
const businessCarouselRef = useTemplateRef('businessCarouselRef')
const newsCarouselRef = useTemplateRef('newsCarouselRef')
const loading = ref(false)
const newsLoading = ref(false)
const router = useRouter()
const isMobile = ref(false)
const activeHeroIndex = ref(0)
let cleanupViewportListener = null
const cases = computed(() => homeData.value.cases.slice(0, 4))
const heroSlides = computed(() => banners.value.length ? banners.value : [])
const businessItems = computed(() => businessSlides.value.length ? businessSlides.value : [])
const advantages = computed(() => {
const list = homeData.value.advantages || []
if (!list.length) {
return defaultAdvantages
}
return list.map((item, index) => ({
icon: item?.cover || defaultAdvantages[index % defaultAdvantages.length]?.icon || advantageIcon0,
title: item?.title || item?.name || defaultAdvantages[index % defaultAdvantages.length]?.title || '企业优势',
description: stripHtml(item?.content || '') || defaultAdvantages[index % defaultAdvantages.length]?.description || '',
}))
})
const advantageStats = computed(() => {
const list = homeData.value.advantage_stats || []
if (!list.length) {
return defaultAdvantageStats
}
return list.map((item, index) => ({
name: item?.name || defaultAdvantageStats[index % defaultAdvantageStats.length]?.name || '',
data: item?.data || defaultAdvantageStats[index % defaultAdvantageStats.length]?.data || '',
unit: item?.unit || defaultAdvantageStats[index % defaultAdvantageStats.length]?.unit || '',
}))
})
const newsGroups = computed(() => {
const size = isMobile.value ? 1 : 3
const groups = []
for (let i = 0; i < newsList.value.length; i += size) {
groups.push(newsList.value.slice(i, i + size))
}
return groups
})
const caseTabs = computed(() => {
const names = Array.from(
new Set(
homeData.value.cases
.map((item) => item.serviceCategory?.name || item.industryCategory?.name || '')
.filter(Boolean),
),
)
return names.length ? names : ['成功案例']
})
const newsTabs = computed(() => {
return articleCategories.value.length
? articleCategories.value
: [
{id: 'company-news', name: '公司新闻'},
{id: 'industry-news', name: '行业资讯'},
]
})
async function loadNewsCategories() {
const categories = await getArticleCategories()
articleCategories.value = categories || []
activeNewsCategoryId.value = articleCategories.value[0]?.id || ''
}
async function loadNewsArticles(categoryId = '') {
newsLoading.value = true
try {
newsList.value = await getArticles(categoryId ? {category_id: categoryId} : {})
} finally {
newsLoading.value = false
}
}
async function changeNewsCategory(id) {
if (id === activeNewsCategoryId.value) return
activeNewsCategoryId.value = id
await loadNewsArticles(id)
}
function openHero(item) {
if (!item?.target_path) return
if (item.target_path.startsWith('/')) {
router.push(item.target_path)
return
}
window.location.href = item.target_path
}
function heroTarget(item) {
return item?.target_path || '#'
}
function heroImage(item) {
return item?.image || businessBg
}
function heroMobileImage(item) {
return item?.mobile_image || item?.image || businessBg
}
function heroIndicatorTitle(item, index) {
return item?.title || `轮播图${index + 1}`
}
function switchHeroSlide(index) {
activeHeroIndex.value = index
heroCarouselRef.value?.setActiveItem(index)
}
function handleHeroChange(index) {
activeHeroIndex.value = index
}
function businessTitle(item) {
return item?.title || item?.name || '核心业务矩阵'
}
function businessCover(item) {
return item?.cover || businessBg
}
function businessMobileCover(item) {
return item?.mobile_cover || item?.cover || businessBg
}
function businessCardStyle(item) {
return {
backgroundImage: `url(${businessCover(item)})`,
}
}
function businessContent(item) {
return item?.content || '<p>暂无内容</p>'
}
function businessTags(item) {
return String(item?.tags || '')
.split(/[,、\n]/)
.map((tag) => tag.trim())
.filter(Boolean)
}
function stripHtml(text) {
return String(text || '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function prevBusinessSlide() {
businessCarouselRef.value?.prev()
}
function nextBusinessSlide() {
businessCarouselRef.value?.next()
}
function prevNewsSlide() {
newsCarouselRef.value?.prev()
}
function nextNewsSlide() {
newsCarouselRef.value?.next()
}
function newsCover(item) {
return item?.cover || item?.news_img || item?.image || emptyContentImage
}
function hasNewsItems() {
return newsList.value.length > 0
}
onMounted(async () => {
const syncViewport = () => {
isMobile.value = window.innerWidth <= 768
}
syncViewport()
window.addEventListener('resize', syncViewport)
cleanupViewportListener = () => window.removeEventListener('resize', syncViewport)
loading.value = true
try {
const [home, bannerList, serviceList] = await Promise.all([
getSiteHome(),
getBanners(),
getServices({type: 'business_matrix'}),
])
homeData.value = home || {cases: [], articles: [], advantages: [], advantage_stats: []}
banners.value = bannerList || []
businessSlides.value = serviceList || []
await loadNewsCategories()
await loadNewsArticles(activeNewsCategoryId.value)
} finally {
loading.value = false
}
})
onBeforeUnmount(() => {
cleanupViewportListener?.()
})
</script>
<template>
<main>
<section class="hero-section">
<div v-if="heroSlides.length" class="hero-media">
<el-carousel
ref="heroCarouselRef"
height="760px"
:autoplay="false"
trigger="click"
indicator-position="none"
arrow="never"
@change="handleHeroChange"
>
<el-carousel-item v-for="item in heroSlides" :key="item.id">
<a class="hero-slide" :href="heroTarget(item)" @click.prevent="openHero(item)">
<picture>
<source :srcset="heroMobileImage(item)" media="(max-width: 768px)">
<img :src="heroImage(item)" :alt="item.title || 'banner'"/>
</picture>
</a>
</el-carousel-item>
</el-carousel>
<div v-if="heroSlides.length > 1" class="hero-indicators" aria-label="首页轮播图切换">
<button
v-for="(item, index) in heroSlides"
:key="item.id || index"
type="button"
:class="['hero-indicator', { active: activeHeroIndex === index }]"
@click="switchHeroSlide(index)"
>
{{ heroIndicatorTitle(item, index) }}
</button>
</div>
</div>
<div v-else class="hero-media hero-media-fallback" :style="{ backgroundImage: `url(${businessBg})` }"></div>
</section>
<section class="section section-gray advantages-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">企业优势</h2>
<p>CORPORATE ADVANTAGES</p>
</div>
<div class="advantages-layout">
<div class="advantages-intro">
<p class="section-kicker">CORPORATE ADVANTAGES</p>
<h3>汇采聚云</h3>
<p class="intro-text">
汇采聚云是围绕政府采购全过程咨询提供专业数智化服务的科技型企业,专注于政府采购领域的系统开发平台推广和运营数智化采购咨询等服务
</p>
<div class="stats-row">
<div v-for="item in advantageStats" :key="item.name" class="stat-item">
<strong>{{ item.data }}</strong>
<span>{{ item.unit }}</span>
<p>{{ item.name }}</p>
</div>
</div>
<a class="text-link" href="/about" @click.prevent="router.push('/about')">详细了解 &gt;&gt;</a>
</div>
<div class="advantages-grid">
<article
v-for="item in advantages"
:key="item.title"
class="adv-card"
>
<div class="adv-icon-wrap">
<img class="adv-icon" :src="item.icon" :alt="item.title"/>
</div>
<h4>{{ item.title }}</h4>
<el-tooltip
:content="item.description"
placement="top"
effect="light"
popper-class="adv-card-tooltip"
>
<p class="adv-card-desc">{{ item.description }}</p>
</el-tooltip>
</article>
</div>
</div>
</div>
</section>
<section class="section business-section">
<div class="container business-container">
<div class="section-heading center business-heading">
<h2 style="color: var(--accent)">核心业务矩阵</h2>
<p>BUSINESS MATRIX</p>
</div>
<div v-if="businessItems.length" class="business-carousel">
<button class="arrow-button business-arrow left" type="button" aria-label="上一项" @click="prevBusinessSlide">
<span></span>
</button>
<el-carousel
ref="businessCarouselRef"
:height="isMobile ? '420px' : '630px'"
:interval="5000"
:indicator-position="isMobile ? 'outside' : 'none'"
arrow="never"
:autoplay="isMobile"
>
<el-carousel-item
v-for="item in businessItems"
:key="item.id"
class="business-carousel-item"
style="border-radius: 15px; border: 1px solid rgba(255,0,0,0.29)"
>
<article
class="business-card business-card-bg"
:style="businessCardStyle(item)"
>
<picture class="business-card-media">
<source :srcset="businessMobileCover(item)" media="(max-width: 768px)">
<img :src="businessCover(item)" :alt="businessTitle(item)">
</picture>
<div class="business-card-mask"></div>
<div class="business-card-content">
<div class="business-card-text rich-content" v-html="businessContent(item)"></div>
<div v-if="businessTags(item).length" class="business-tag-list">
<span v-for="tag in businessTags(item)" :key="tag" class="business-tag">{{ tag }}</span>
</div>
</div>
</article>
</el-carousel-item>
</el-carousel>
<button class="arrow-button business-arrow right" type="button" aria-label="下一项"
@click="nextBusinessSlide">
<span></span>
</button>
</div>
</div>
</section>
<section class="section section-gray cases-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">成功案例</h2>
<p>SUCCESS CASES</p>
</div>
<div class="tab-row">
<a v-for="tab in caseTabs" :key="tab" href="#" class="tab-link">
{{ tab }}
</a>
</div>
<div v-if="loading && !cases.length" class="info-card">
<p>正在加载成功案例...</p>
</div>
<div v-else class="cases-grid">
<article
v-for="item in cases"
:key="item.id"
class="case-card"
:style="{
backgroundImage: `url(${caseBg})`,
'--case-bg-active': `url(${caseBgActive})`,
}"
>
<p class="case-category">{{ item.serviceCategory?.name || item.industryCategory?.name || '成功案例' }}</p>
<h3>{{ item.client_name }}</h3>
<p class="case-subtitle">{{ item.title || item.industry }}</p>
<RouterLink
:to="{ name: 'case-detail', params: { slug: item.id } }"
class="ghost-button"
>
了解详情
</RouterLink>
</article>
</div>
<div class="center-action">
<RouterLink class="outline-button more-button" :to="{ name: 'cases' }">更多案例</RouterLink>
</div>
</div>
</section>
<section class="section news-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">新闻动态</h2>
<p>NEWS &amp; UPDATES</p>
</div>
<div class="tab-row compact">
<button
v-for="tab in newsTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: tab.id === activeNewsCategoryId }]"
@click="changeNewsCategory(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div class="news-carousel-wrap">
<button class="arrow-button muted news-arrow left" type="button" aria-label="上一条" @click="prevNewsSlide">
<span></span>
</button>
<div class="news-carousel">
<div v-if="(loading || newsLoading) && !newsList.length" class="info-card">
<p>正在加载新闻动态...</p>
</div>
<div v-else-if="!hasNewsItems()" class="news-empty">
<img :src="emptyContentImage" alt="暂无内容" />
<p>暂无新闻内容</p>
</div>
<el-carousel
v-else
ref="newsCarouselRef"
class="news-slider"
:height="isMobile ? '400px' : '430px'"
arrow="never"
:indicator-position="isMobile ? 'outside' : 'none'"
:autoplay="isMobile"
trigger="click"
:loop="newsGroups.length > 1"
>
<el-carousel-item v-for="(group, index) in newsGroups" :key="index">
<div class="news-grid">
<RouterLink
v-for="item in group"
:key="item.id"
class="news-card"
:to="{ name: 'news-detail', params: { slug: item.id } }"
>
<div class="news-image">
<img :src="newsCover(item)" :alt="item.title"/>
</div>
<h3>{{ item.title }}</h3>
<time>{{ item.published_at || item.created_at }}</time>
</RouterLink>
</div>
</el-carousel-item>
</el-carousel>
</div>
<button class="arrow-button muted news-arrow right" type="button" aria-label="下一条" @click="nextNewsSlide">
<span></span>
</button>
</div>
<div class="center-action news-more-action">
<RouterLink class="outline-button more-button" :to="{ name: 'news' }">更多新闻</RouterLink>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getArticle, getArticles } from '../utils/api'
const route = useRoute()
const article = ref(null)
const newsList = ref([])
const articleIndex = computed(() => {
return newsList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? newsList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < newsList.value.length - 1 ? newsList.value[index + 1] : null
})
async function loadNewsDetail() {
article.value = await getArticle(route.params.slug)
newsList.value = await getArticles(article.value?.category_id ? { category_id: article.value.category_id } : {})
}
watch(
() => route.params.slug,
async () => {
await loadNewsDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="{ name: 'news' }">新闻动态</RouterLink>
<span>/</span>
<em>{{ article.category?.name || '新闻详情' }}</em>
</div>
<!-- <p class="article-category">{{ article.category?.name || '新闻动态' }}</p>-->
<h1>{{ article.title }}</h1>
<div class="article-meta">
<span>{{ article.author || '汇采聚云' }}</span>
<span>{{ article.published_at || article.created_at }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div class="article-cover">
<img :src="article.cover || article.news_img || article.image" :alt="article.title" />
</div>
<div class="article-summary">
<p>{{ article.summary || '暂无摘要' }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无内容</p>'">
</div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="{ name: 'news' }">返回新闻列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关新闻</h3>
<RouterLink
v-for="item in newsList"
:key="item.id"
:to="{ name: 'news-detail', params: { slug: item.id } }"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img :src="item.cover || item.news_img || item.image" :alt="item.title" />
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.published_at || item.created_at }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="{ name: 'news-detail', params: { slug: prevArticle.id } }"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="{ name: 'news-detail', params: { slug: nextArticle.id } }"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载新闻详情...</p></article>
</div>
</section>
</main>
</template>

119
src/views/NewsView.vue Normal file
View File

@@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import emptyContentImage from '../assets/暂无内容.png'
import { getArticleCategories, getArticles } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const tabs = ref([])
const newsList = ref([])
const activeCategoryId = ref('')
const pageDescription = usePageDescription('news')
async function loadCategories() {
const res = await getArticleCategories()
tabs.value = res || []
}
function syncActiveCategory() {
const queryCategoryId = String(route.query.category_id || '')
const matchedTab = tabs.value.find((item) => String(item.id) === queryCategoryId)
activeCategoryId.value = matchedTab?.id || tabs.value[0]?.id || ''
}
async function loadArticles(categoryId = '') {
newsList.value = await getArticles(categoryId ? { category_id: categoryId } : {})
}
async function changeCategory(id) {
await router.replace({
path: '/news',
query: {
category_id: id,
},
})
}
function newsCover(item) {
return item?.cover || item?.news_img || item?.image || emptyContentImage
}
onMounted(async () => {
await loadCategories()
syncActiveCategory()
await loadArticles(activeCategoryId.value)
})
watch(
() => route.query.category_id,
async () => {
if (!tabs.value.length) {
await loadCategories()
}
syncActiveCategory()
await loadArticles(activeCategoryId.value)
},
)
</script>
<template>
<main class="news-page">
<section class="news-page-hero">
<div class="container news-page-hero-inner">
<div class="news-page-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>新闻动态</em>
</div>
<p class="news-page-kicker">NEWS &amp; UPDATES</p>
<h1>新闻动态</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="news-page-section">
<div class="container">
<div class="tab-row compact news-page-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: tab.id === activeCategoryId }]"
@click="changeCategory(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div v-if="!newsList.length" class="news-page-empty">
<img :src="emptyContentImage" alt="暂无内容" />
<p>暂无新闻内容</p>
</div>
<div v-else class="news-page-grid">
<RouterLink
v-for="item in newsList"
:key="item.id"
class="news-page-card"
:to="{ name: 'news-detail', params: { slug: item.id } }"
>
<div class="news-page-image">
<img :src="newsCover(item)" :alt="item.title" />
</div>
<div class="news-page-body">
<p class="news-page-category">{{ item.category?.name || item.category_name || '新闻动态' }}</p>
<h3>{{ item.title }}</h3>
<p class="news-page-summary">{{ item.summary || '暂无摘要' }}</p>
<div class="news-page-meta">
<span>{{ item.author || '汇采聚云' }}</span>
<time>{{ item.published_at || item.created_at }}</time>
</div>
</div>
</RouterLink>
</div>
</div>
</section>
</main>
</template>

43
src/views/NoticeView.vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>公告声明</em>
</div>
<p class="info-kicker">NOTICE</p>
<h1>公告声明</h1>
<p>
本页面用于展示汇采聚云官方网站的基础声明信息包括内容说明服务边界与信息更新说明等请在使用本站服务前仔细阅读
</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<article class="info-card">
<h2>内容说明</h2>
<p>
本网站展示的产品信息服务介绍案例内容新闻动态及相关说明旨在为用户了解公司业务范围与服务能力提供参考部分页面为演示内容具体服务信息以双方正式沟通和确认结果为准
</p>
</article>
<article class="info-card">
<h2>服务边界</h2>
<p>
网站中涉及的产品功能咨询服务培训支持及运营推广方案可能因不同单位的业务场景制度要求与实施范围有所调整最终交付内容合作模式与实施路径应以正式方案合同及项目约定为准
</p>
</article>
<article class="info-card">
<h2>信息更新说明</h2>
<p>
我们会持续对网站内容进行维护和更新但不保证所有展示信息在任意时点均保持完全同步如您对具体内容存在疑问欢迎通过联系我们业务咨询页面与我们取得联系
</p>
</article>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,162 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getPartyCulture, getPartyCultures } from '../utils/api'
const route = useRoute()
const culture = ref(null)
const relatedList = ref([])
const cultureIndex = computed(() => {
return relatedList.value.findIndex((item) => String(item.id) === String(culture.value?.id))
})
const prevCulture = computed(() => {
const index = cultureIndex.value
return index > 0 ? relatedList.value[index - 1] : null
})
const nextCulture = computed(() => {
const index = cultureIndex.value
return index >= 0 && index < relatedList.value.length - 1 ? relatedList.value[index + 1] : null
})
const listRoute = computed(() => ({
name: 'party-culture',
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
...(route.query.page ? { page: route.query.page } : {}),
},
}))
function detailRoute(item) {
const id = item?.id || item
const categoryId = item?.category_id || route.query.category_id
return {
name: 'party-culture-detail',
params: { slug: id },
query: {
...(categoryId ? { category_id: categoryId } : {}),
...(route.query.page ? { page: route.query.page } : {}),
},
}
}
function normalizeList(data) {
if (Array.isArray(data)) return data
return data?.list || []
}
async function loadDetail() {
culture.value = await getPartyCulture(route.params.slug)
const categoryId = culture.value?.category_id || route.query.category_id
const data = await getPartyCultures(categoryId ? { category_id: categoryId } : {}).catch(() => [])
relatedList.value = normalizeList(data)
}
watch(
() => [route.params.slug, route.query.category_id],
async () => {
await loadDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="culture" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">党建文化</RouterLink>
<span>/</span>
<em>内容详情</em>
</div>
<p class="article-category">{{ culture.category?.name || '党建文化' }}</p>
<h1>{{ culture.title || culture.name }}</h1>
<div class="article-meta">
<span>{{ culture.name }}</span>
<span>{{ culture.created_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="culture.cover" class="article-cover">
<img :src="culture.cover" :alt="culture.name" />
</div>
<div class="article-summary">
<p>{{ culture.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="culture.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回党建文化</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关内容</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item)"
:class="['related-news-item', { active: item.id === culture.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title || item.name }}</strong>
<span>{{ item.category?.name || '党建文化' }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevCulture"
class="article-pager-link"
:to="detailRoute(prevCulture)"
>
<label>上一篇</label>
<strong>{{ prevCulture.title || prevCulture.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一条</strong>
</div>
<RouterLink
v-if="nextCulture"
class="article-pager-link"
:to="detailRoute(nextCulture)"
>
<label>下一篇</label>
<strong>{{ nextCulture.title || nextCulture.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一条</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载党建文化详情...</p></article>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,259 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getPartyCultureCategories, getPartyCultures } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const PAGE_SIZE = 5
const rootCategories = ref([])
const cultureList = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const currentPage = ref(1)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const pageDescription = usePageDescription('partyCulture')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
const pageNumbers = computed(() => {
if (!totalPages.value) return []
const pages = new Set([1, totalPages.value, currentPage.value - 1, currentPage.value, currentPage.value + 1])
return [...pages]
.filter((page) => page >= 1 && page <= totalPages.value)
.sort((a, b) => a - b)
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
rootCategories.value = await getPartyCultureCategories({ status: 1 }).catch(() => [])
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
const page = Number(route.query.page || 1)
currentPage.value = Number.isFinite(page) && page > 0 ? page : 1
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadCultures() {
loading.value = true
try {
const categoryId = activeLeafCategoryId.value || activeRootCategoryId.value || ''
const res = await getPartyCultures({
...(categoryId ? { category_id: categoryId } : {}),
page: currentPage.value,
per_page: PAGE_SIZE,
})
cultureList.value = res?.list || []
total.value = Number(res?.pagination?.total || 0)
totalPages.value = Number(res?.pagination?.total_pages || 0)
} finally {
loading.value = false
}
}
async function updateRoute(categoryId, page = 1) {
await router.replace({
path: '/party-culture',
query: {
category_id: categoryId,
...(page > 1 ? { page } : {}),
},
})
}
async function changeLeafCategory(id) {
await updateRoute(id, 1)
}
async function changePage(page) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
await updateRoute(activeLeafCategoryId.value || activeRootCategoryId.value || '', page)
}
function detailRoute(item) {
return {
name: 'party-culture-detail',
params: { slug: item.id },
query: {
...(item.category_id ? { category_id: item.category_id } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadCultures()
})
watch(
() => [route.query.category_id, route.query.page],
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadCultures()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>党建文化</em>
</div>
<p class="info-kicker">PARTY BUILDING</p>
<h1>党建文化</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeLeafCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无党建文化分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '党建文化' }}</h2>
<p v-if="total"> {{ total }} 条内容</p>
</div>
</div>
<div v-if="cultureList.length" class="product-list-grid">
<RouterLink
v-for="item in cultureList"
:key="item.id"
:to="detailRoute(item)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '党建文化' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name || '查看党建文化详情' }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载党建文化数据...' : '暂无党建文化数据' }}</p>
</div>
<div v-if="totalPages > 1" class="product-pagination">
<button
type="button"
class="product-page-button"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in pageNumbers"
:key="page"
type="button"
:class="['product-page-button', { active: page === currentPage }]"
@click="changePage(page)"
>
{{ page }}
</button>
<button
type="button"
class="product-page-button"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getProduct, getProductCategories, getProducts } from '../utils/api'
const route = useRoute()
const ROOT_CATEGORY_ID = '1'
const product = ref(null)
const relatedList = ref([])
const pagerList = ref([])
const productIndex = computed(() => {
return pagerList.value.findIndex((item) => String(item.id) === String(product.value?.id))
})
const prevProduct = computed(() => {
const index = productIndex.value
return index > 0 ? pagerList.value[index - 1] : null
})
const nextProduct = computed(() => {
const index = productIndex.value
return index >= 0 && index < pagerList.value.length - 1 ? pagerList.value[index + 1] : null
})
const listRoute = computed(() => ({
name: 'products',
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
...(route.query.page ? { page: route.query.page } : {}),
type: 'product',
},
}))
function detailRoute(item) {
const id = item?.id || item
const categoryId = item?.category_id || route.query.category_id
return {
name: 'product-detail',
params: { slug: id },
query: {
...(categoryId ? { category_id: categoryId } : {}),
...(route.query.page ? { page: route.query.page } : {}),
type: 'product',
},
}
}
function normalizeProductList(data) {
if (Array.isArray(data)) return data
return data?.list || []
}
function getProductCenterCategoryIds(categories) {
const root = (categories || []).find((item) => String(item.id) === ROOT_CATEGORY_ID)
if (!root) return []
return root.children?.length ? root.children.map((item) => String(item.id)) : [String(root.id)]
}
async function loadProductDetail() {
product.value = await getProduct(route.params.slug)
const categoryId = product.value?.category_id || route.query.category_id
const [relatedProducts, allProducts, categories] = await Promise.all([
getProducts(categoryId ? { category_id: categoryId, type: 'product' } : { type: 'product' }).catch(() => []),
getProducts({ type: 'product' }).catch(() => []),
getProductCategories({ status: 1 }).catch(() => []),
])
const productCenterCategoryIds = getProductCenterCategoryIds(categories)
relatedList.value = normalizeProductList(relatedProducts)
pagerList.value = normalizeProductList(allProducts).filter((item) => {
return !productCenterCategoryIds.length || productCenterCategoryIds.includes(String(item.category_id))
})
}
watch(
() => route.params.slug,
async () => {
await loadProductDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="product" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">产品中心</RouterLink>
<span>/</span>
<em>产品详情</em>
</div>
<!-- <p class="article-category">{{ product.category?.name || '产品中心' }}</p>-->
<h1>{{ product.title || product.name }}</h1>
<div class="article-meta">
<span>{{ product.name }}</span>
<span>{{ product.category?.parent?.name || product.category?.name || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="product.cover" class="article-cover">
<img :src="product.cover" :alt="product.name" />
</div>
<div class="article-summary">
<p>{{ product.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="product.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回产品列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关产品</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item)"
:class="['related-news-item', { active: item.id === product.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title || item.name }}</strong>
<span>{{ item.category?.name || '产品中心' }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevProduct"
class="article-pager-link"
:to="detailRoute(prevProduct)"
>
<label>上一篇</label>
<strong>{{ prevProduct.title || prevProduct.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一条</strong>
</div>
<RouterLink
v-if="nextProduct"
class="article-pager-link"
:to="detailRoute(nextProduct)"
>
<label>下一篇</label>
<strong>{{ nextProduct.title || nextProduct.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一条</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载产品详情...</p></article>
</div>
</section>
</main>
</template>

263
src/views/ProductsView.vue Normal file
View File

@@ -0,0 +1,263 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getProductCategories, getProducts } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const ROOT_CATEGORY_ID = '1'
const PAGE_SIZE = 5
const rootCategories = ref([])
const productList = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const currentPage = ref(1)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const pageDescription = usePageDescription('products')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
const pageNumbers = computed(() => {
if (!totalPages.value) return []
const pages = new Set([1, totalPages.value, currentPage.value - 1, currentPage.value, currentPage.value + 1])
return [...pages]
.filter((page) => page >= 1 && page <= totalPages.value)
.sort((a, b) => a - b)
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
const categories = await getProductCategories({ status: 1 }).catch(() => [])
rootCategories.value = (categories || []).filter((item) => String(item.id) === ROOT_CATEGORY_ID)
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
const page = Number(route.query.page || 1)
currentPage.value = Number.isFinite(page) && page > 0 ? page : 1
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadProducts() {
loading.value = true
try {
const categoryId = activeLeafCategoryId.value || activeRootCategoryId.value || ''
const res = await getProducts({
...(categoryId ? { category_id: categoryId } : {}),
page: currentPage.value,
per_page: PAGE_SIZE,
})
productList.value = res?.list || []
total.value = Number(res?.pagination?.total || 0)
totalPages.value = Number(res?.pagination?.total_pages || 0)
} finally {
loading.value = false
}
}
async function updateRoute(categoryId, page = 1) {
await router.replace({
path: '/products',
query: {
type: 'product',
category_id: categoryId,
...(page > 1 ? { page } : {}),
},
})
}
async function changeLeafCategory(id) {
await updateRoute(id, 1)
}
async function changePage(page) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
await updateRoute(activeLeafCategoryId.value || activeRootCategoryId.value || '', page)
}
function detailRoute(id) {
return {
name: 'product-detail',
params: { slug: id },
query: {
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
type: 'product',
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadProducts()
})
watch(
() => [route.query.category_id, route.query.page],
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadProducts()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>产品中心</em>
</div>
<p class="info-kicker">PRODUCT CENTER</p>
<h1>产品中心</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeLeafCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无产品分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '产品分类' }}</h2>
<p v-if="total"> {{ total }} 条产品</p>
</div>
</div>
<div v-if="productList.length" class="product-list-grid">
<RouterLink
v-for="item in productList"
:key="item.id"
:to="detailRoute(item.id)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '产品中心' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载产品数据...' : '暂无产品数据' }}</p>
</div>
<div v-if="totalPages > 1" class="product-pagination">
<button
type="button"
class="product-page-button"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in pageNumbers"
:key="page"
type="button"
:class="['product-page-button', { active: page === currentPage }]"
@click="changePage(page)"
>
{{ page }}
</button>
<button
type="button"
class="product-page-button"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getService, getServices } from '../utils/api'
const route = useRoute()
const article = ref(null)
const relatedList = ref([])
const isConsulting = computed(() => route.path.startsWith('/consulting/'))
const listRouteName = computed(() => (isConsulting.value ? 'consulting' : 'services'))
const listLabel = computed(() => (isConsulting.value ? '数智咨询' : '运营服务'))
const articleIndex = computed(() => {
return relatedList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? relatedList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < relatedList.value.length - 1 ? relatedList.value[index + 1] : null
})
function detailRoute(id) {
return {
name: isConsulting.value ? 'consulting-detail' : 'service-detail',
params: { slug: id },
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
},
}
}
const listRoute = computed(() => ({
name: listRouteName.value,
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
},
}))
async function loadDetail() {
article.value = await getService(route.params.slug)
relatedList.value = await getServices({
type: article.value?.type || (isConsulting.value ? 'consulting' : 'operations'),
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
})
}
watch(
() => [route.params.slug, route.query.category_id],
async () => {
await loadDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">{{ listLabel }}</RouterLink>
<span>/</span>
<em>服务详情</em>
</div>
<p class="article-category">{{ article.type === 'consulting' ? '数智咨询' : '运营服务' }}</p>
<h1>{{ article.title }}</h1>
<div class="article-meta">
<span>{{ article.name }}</span>
<span>{{ article.created_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="article.cover" class="article-cover">
<img :src="article.cover" :alt="article.name" />
</div>
<div class="article-summary">
<p>{{ article.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回{{ listLabel }}</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关内容</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item.id)"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.name }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="detailRoute(prevArticle.id)"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="detailRoute(nextArticle.id)"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载详情...</p></article>
</div>
</section>
</main>
</template>

199
src/views/ServicesView.vue Normal file
View File

@@ -0,0 +1,199 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getProductCategories, getServices } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const ROOT_CATEGORY_ID = '2'
const rootCategories = ref([])
const services = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('services')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
const categories = await getProductCategories({ status: 1 }).catch(() => [])
rootCategories.value = (categories || []).filter((item) => String(item.id) === ROOT_CATEGORY_ID)
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadServicesList() {
loading.value = true
try {
services.value = await getServices({
type: 'operations',
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
})
} finally {
loading.value = false
}
}
async function changeCategory(id) {
await router.replace({
path: '/services',
query: id ? { category_id: id } : {},
})
}
function detailRoute(id) {
return {
name: 'service-detail',
params: { slug: id },
query: {
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadServicesList()
})
watch(
() => route.query.category_id,
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadServicesList()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>运营服务</em>
</div>
<p class="info-kicker">OPERATIONS SERVICE</p>
<h1>运营服务</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无运营服务分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '运营服务' }}</h2>
<p v-if="services.length"> {{ services.length }} 条服务</p>
</div>
</div>
<div v-if="services.length" class="product-list-grid">
<RouterLink
v-for="item in services"
:key="item.id"
:to="detailRoute(item.id)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div v-else class="product-list-cover"></div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '运营服务' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name || '查看服务详情' }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载运营服务数据...' : '暂无运营服务数据' }}</p>
</div>
</article>
</section>
</div>
</section>
</main>
</template>