修改了首页
This commit is contained in:
190
app/components/Carousel.vue
Normal file
190
app/components/Carousel.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<section class="carousel-wrap">
|
||||
<div class="carousel-panel" :style="{ height: heightCss }">
|
||||
<ClientOnly>
|
||||
<a-carousel
|
||||
ref="carouselRef"
|
||||
class="carousel"
|
||||
:autoplay="autoplayEnabled"
|
||||
:autoplay-speed="autoplaySpeed"
|
||||
:dots="dotsEnabled"
|
||||
:effect="effect"
|
||||
:style="{ height: heightCss }"
|
||||
>
|
||||
<div
|
||||
v-for="(it, idx) in normalizedItems"
|
||||
:key="`${it.src}::${idx}`"
|
||||
class="carousel-item"
|
||||
:style="{ height: heightCss }"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="it.href && !isExternal(it.href)"
|
||||
:to="it.href"
|
||||
class="carousel-link"
|
||||
aria-label="carousel-slide"
|
||||
>
|
||||
<img class="carousel-img" :src="it.src" :alt="it.alt || 'slide'" loading="lazy" />
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else-if="it.href"
|
||||
class="carousel-link"
|
||||
:href="it.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="carousel-slide"
|
||||
>
|
||||
<img class="carousel-img" :src="it.src" :alt="it.alt || 'slide'" loading="lazy" />
|
||||
</a>
|
||||
<img v-else class="carousel-img" :src="it.src" :alt="it.alt || 'slide'" loading="lazy" />
|
||||
</div>
|
||||
</a-carousel>
|
||||
|
||||
<button
|
||||
v-if="showNav"
|
||||
type="button"
|
||||
class="carousel-nav carousel-nav-left"
|
||||
aria-label="prev"
|
||||
@click.prevent.stop="prev"
|
||||
>
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showNav"
|
||||
type="button"
|
||||
class="carousel-nav carousel-nav-right"
|
||||
aria-label="next"
|
||||
@click.prevent.stop="next"
|
||||
>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
|
||||
<template #fallback>
|
||||
<img class="carousel-img" :style="{ height: heightCss }" :src="fallbackSrc" alt="slide" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type CarouselItem = {
|
||||
src: string
|
||||
href?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: CarouselItem[]
|
||||
height?: number | string
|
||||
autoplaySpeed?: number
|
||||
effect?: 'scrollx' | 'fade'
|
||||
}>(),
|
||||
{
|
||||
height: 360,
|
||||
autoplaySpeed: 4500,
|
||||
effect: 'fade'
|
||||
}
|
||||
)
|
||||
|
||||
type AntdCarouselExpose = {
|
||||
next: () => void
|
||||
prev: () => void
|
||||
goTo: (slide: number, dontAnimate?: boolean) => void
|
||||
}
|
||||
|
||||
const carouselRef = ref<AntdCarouselExpose | null>(null)
|
||||
|
||||
const heightCss = computed(() => (typeof props.height === 'number' ? `${props.height}px` : props.height))
|
||||
|
||||
const normalizedItems = computed(() => (props.items || []).filter((it) => it && typeof it.src === 'string' && it.src))
|
||||
|
||||
const autoplayEnabled = computed(() => normalizedItems.value.length > 1)
|
||||
const dotsEnabled = computed(() => normalizedItems.value.length > 1)
|
||||
const showNav = computed(() => normalizedItems.value.length > 1)
|
||||
|
||||
const fallbackSrc = computed(() => normalizedItems.value[0]?.src || '')
|
||||
|
||||
function isExternal(href: string) {
|
||||
return /^https?:\/\//i.test(href)
|
||||
}
|
||||
|
||||
function next() {
|
||||
carouselRef.value?.next()
|
||||
}
|
||||
|
||||
function prev() {
|
||||
carouselRef.value?.prev()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.carousel-panel {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.carousel :deep(.slick-list),
|
||||
.carousel :deep(.slick-track),
|
||||
.carousel :deep(.slick-slide),
|
||||
.carousel :deep(.slick-slide > div) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 5;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.carousel-nav:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.carousel-nav-left {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.carousel-nav-right {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.carousel-nav span {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
220
app/components/QrLogin.vue
Normal file
220
app/components/QrLogin.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="qr-login">
|
||||
<div class="qr-box">
|
||||
<div v-if="status === 'loading'" class="qr-state">
|
||||
<a-spin size="large" />
|
||||
<p class="muted">正在生成二维码…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'active'" class="qr-state">
|
||||
<a-qrcode :value="qrCodeUrl" :size="200" @click="refresh" />
|
||||
<p class="tip">请使用手机 APP 或小程序扫码登录</p>
|
||||
<p class="muted">有效期:{{ formatCountdown(expireSeconds) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'scanned'" class="qr-state">
|
||||
<CheckCircleOutlined class="icon ok" />
|
||||
<p class="ok-text">扫码成功,请在手机上确认登录</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'expired'" class="qr-state">
|
||||
<ClockCircleOutlined class="icon bad" />
|
||||
<p class="bad-text">二维码已过期</p>
|
||||
<a-button type="primary" @click="refresh">刷新二维码</a-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'error'" class="qr-state">
|
||||
<ExclamationCircleOutlined class="icon bad" />
|
||||
<p class="bad-text">{{ errorMessage || '生成二维码失败' }}</p>
|
||||
<a-button type="primary" @click="refresh">重新生成</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="status === 'active'" class="actions">
|
||||
<a-button type="link" :loading="refreshing" @click="refresh">
|
||||
<ReloadOutlined />
|
||||
刷新二维码
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
checkQrCodeStatus,
|
||||
generateQrCode,
|
||||
type QrCodeResponse,
|
||||
type QrCodeStatusResponse
|
||||
} from '@/api/passport/qrLogin'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loginSuccess', data: QrCodeStatusResponse): void
|
||||
(e: 'loginError', error: string): void
|
||||
}>()
|
||||
|
||||
const qrCodeUrl = ref('')
|
||||
const token = ref('')
|
||||
const status = ref<'loading' | 'active' | 'scanned' | 'expired' | 'error'>('loading')
|
||||
const expireSeconds = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const refreshing = ref(false)
|
||||
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopTimers() {
|
||||
if (statusTimer) clearInterval(statusTimer)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
statusTimer = null
|
||||
countdownTimer = null
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.max(0, seconds % 60)
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
const msg = (error as { message?: unknown }).message
|
||||
if (typeof msg === 'string') return msg
|
||||
}
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!import.meta.client) return
|
||||
stopTimers()
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response: QrCodeResponse = await generateQrCode()
|
||||
token.value = response.token
|
||||
expireSeconds.value = response.expiresIn || 300
|
||||
qrCodeUrl.value = `${window.location.origin}/qr-confirm?qrCodeKey=${encodeURIComponent(
|
||||
response.token
|
||||
)}`
|
||||
status.value = 'active'
|
||||
|
||||
statusTimer = setInterval(async () => {
|
||||
try {
|
||||
const current = await checkQrCodeStatus(token.value)
|
||||
if (current.expiresIn !== undefined) expireSeconds.value = current.expiresIn
|
||||
if (current.status === 'scanned') {
|
||||
status.value = 'scanned'
|
||||
return
|
||||
}
|
||||
if (current.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
stopTimers()
|
||||
return
|
||||
}
|
||||
if (current.status === 'confirmed') {
|
||||
stopTimers()
|
||||
emit('loginSuccess', current)
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors, keep polling
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
expireSeconds.value = Math.max(0, expireSeconds.value - 1)
|
||||
if (expireSeconds.value <= 0) {
|
||||
status.value = 'expired'
|
||||
stopTimers()
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error: unknown) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = getErrorMessage(error) || '生成二维码失败'
|
||||
message.error(errorMessage.value)
|
||||
emit('loginError', errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await init()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0 12px;
|
||||
}
|
||||
.qr-box {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qr-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.tip {
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
.bad {
|
||||
color: #ef4444;
|
||||
}
|
||||
.ok-text {
|
||||
margin: 0;
|
||||
color: #16a34a;
|
||||
}
|
||||
.bad-text {
|
||||
margin: 0;
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
134
app/components/RichText.vue
Normal file
134
app/components/RichText.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div v-if="mode === 'html'" class="rich-text" v-html="normalizedHtml" />
|
||||
<div v-else class="rich-text whitespace-pre-wrap break-words">
|
||||
{{ text }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
content?: string | null
|
||||
}>()
|
||||
|
||||
const text = computed(() => (typeof props.content === 'string' ? props.content : ''))
|
||||
|
||||
// Heuristic: treat as HTML only when it looks like it contains tags.
|
||||
const mode = computed(() => (/<[a-z][\s\S]*>/i.test(text.value) ? 'html' : 'text'))
|
||||
|
||||
function escapeHtml(input: string) {
|
||||
return input
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const normalizedHtml = computed(() => {
|
||||
const raw = text.value.trim()
|
||||
if (!raw) return ''
|
||||
|
||||
// Some CMS fields store JSON; try to pull out a common HTML field.
|
||||
if (
|
||||
(raw.startsWith('{') && raw.endsWith('}')) ||
|
||||
(raw.startsWith('[') && raw.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
|
||||
let candidate = ''
|
||||
if (typeof parsed === 'string') candidate = parsed
|
||||
else if (isRecord(parsed)) {
|
||||
const html = parsed.html
|
||||
const content = parsed.content
|
||||
const body = parsed.body
|
||||
if (typeof html === 'string') candidate = html
|
||||
else if (typeof content === 'string') candidate = content
|
||||
else if (typeof body === 'string') candidate = body
|
||||
}
|
||||
|
||||
if (candidate && /<[a-z][\s\S]*>/i.test(candidate)) return candidate
|
||||
|
||||
// Fallback: render JSON as <pre>.
|
||||
return `<pre class="rich-pre">${escapeHtml(JSON.stringify(parsed, null, 2))}</pre>`
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return raw
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
line-height: 1.75;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rich-text :deep(h1) {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
|
||||
.rich-text :deep(h2) {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.rich-text :deep(h3) {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.rich-text :deep(p) {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.rich-text :deep(a) {
|
||||
color: #1677ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-text :deep(ul),
|
||||
.rich-text :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.rich-text :deep(li) {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.rich-text :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.rich-text :deep(blockquote) {
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 4px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.rich-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
81
app/components/SiteFooter.vue
Normal file
81
app/components/SiteFooter.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<a-layout-footer class="footer">
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-10">
|
||||
<a-row :gutter="[24, 24]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<div class="text-base font-semibold text-white">关注我们</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<a-avatar shape="square" :size="96" src="https://oss.wsdns.cn/20260127/74041127623e4a8faa49a24e0818dae6.png" />
|
||||
<div class="text-sm leading-6 text-gray-400">
|
||||
小程序
|
||||
<br />
|
||||
获取最新动态
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="8">
|
||||
<div class="text-base font-semibold text-white">快速入口</div>
|
||||
<div class="mt-4 grid gap-2 text-sm text-gray-400">
|
||||
<NuxtLink class="hover:text-white" to="/products">经营范围</NuxtLink>
|
||||
<NuxtLink class="hover:text-white" to="/contact">联系我们</NuxtLink>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="8">
|
||||
<div class="text-base font-semibold text-white">备案信息</div>
|
||||
<div class="mt-4 text-sm text-gray-400">{{ icpText }}</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col gap-2 border-t border-white/10 pt-6 text-xs text-gray-500 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>© {{ year }} {{ siteName }}. All rights reserved.</div>
|
||||
<div class="tools flex items-center opacity-80 hover:opacity-90 text-gray-100 text-xs">
|
||||
Powered by
|
||||
<a
|
||||
rel="nofollow"
|
||||
href="https://glt.websoft.top"
|
||||
target="_blank"
|
||||
class="text-white visited:text-white hover:text-gray-200 ml-1"
|
||||
>
|
||||
云·企业官网
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCmsWebsiteFieldByCode } from '@/api/cms/cmsWebsiteField'
|
||||
|
||||
const { data: siteInfo } = useSiteInfo()
|
||||
const siteName = computed(() => String((siteInfo.value as any)?.data?.websiteName || '桂乐淘'))
|
||||
|
||||
const { data: icpField } = useAsyncData('cms-website-field-icpNo', async () => {
|
||||
try {
|
||||
return await getCmsWebsiteFieldByCode('icpNo')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const icpNo = computed(() => {
|
||||
const v = icpField.value?.value ?? icpField.value?.defaultValue
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
const fallback = (siteInfo.value as any)?.data?.icpNo
|
||||
return typeof fallback === 'string' ? fallback.trim() : ''
|
||||
})
|
||||
|
||||
const icpText = computed(() => (icpNo.value ? `备案号:${icpNo.value}` : '备案号:'))
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
background: #4b5563;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
447
app/components/SiteHeader.vue
Normal file
447
app/components/SiteHeader.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<div class="topbar">
|
||||
<div class="mx-auto flex max-w-screen-xl items-center justify-between gap-4 px-4">
|
||||
<div class="topbar-left">
|
||||
<span class="topbar-date">{{ todayText }}</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<a-button size="small" @click="navigateTo('/join')">招商加盟</a-button>
|
||||
<a-button v-if="isLoggedIn" size="small" type="primary" @click="navigateTo('/console')">用户中心</a-button>
|
||||
<a-button v-else size="small" type="primary" @click="navigateTo('/login')">会员登录</a-button>
|
||||
</div>
|
||||
|
||||
<a-button class="md:hidden" size="small" @click="open = true">菜单</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showBrandbar" class="brandbar">
|
||||
<div class="brandbar-inner mx-auto grid max-w-screen-xl grid-cols-12 items-center gap-6 px-4 py-4">
|
||||
<NuxtLink to="/" class="col-span-12 flex items-center gap-4 md:col-span-6">
|
||||
<img class="brand-logo" :src="`https://oss.wsdns.cn/20260127/989e5cf82b0847ed9168023baf68f4a9.png`" :alt="siteName" />
|
||||
</NuxtLink>
|
||||
|
||||
<div class="col-span-12 hidden text-right md:col-span-6 md:block">
|
||||
<div class="brand-mission">{{ missionText }}</div>
|
||||
<div class="brand-values">{{ valuesText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<a-affix :offset-top="0" @change="onAffixChange">
|
||||
<div class="navbar">
|
||||
<div class="mx-auto flex max-w-screen-xl items-center justify-between gap-3 px-4">
|
||||
<NuxtLink v-if="isAffixed" to="/" class="navbar-brand">
|
||||
<img class="navbar-logo" :src="logoUrl" :alt="siteName" />
|
||||
<span class="navbar-brand-name">
|
||||
{{ siteName }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<nav class="nav hidden md:flex">
|
||||
<template v-for="item in navItems" :key="item.key">
|
||||
<a-dropdown v-if="item.children?.length" :trigger="['hover', 'click']">
|
||||
<a class="nav-link" :class="{ active: isActive(item) }" @click.prevent>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
@click="onNavClick(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<NuxtLink
|
||||
v-else-if="item.to"
|
||||
class="nav-link"
|
||||
:class="{ active: isActive(item) }"
|
||||
:to="item.to"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</a-affix>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a-drawer v-model:open="open" title="导航" placement="right">
|
||||
<a-menu mode="inline" :selected-keys="selectedKeys">
|
||||
<template v-for="item in navItems" :key="item.key">
|
||||
<a-menu-item v-if="!item.children?.length" :key="item.key" @click="onNavClick(item)">
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
<a-sub-menu v-else :key="item.key" :title="item.label">
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
@click="onNavClick(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
|
||||
<div class="mt-4">
|
||||
<a-space direction="vertical" class="w-full">
|
||||
<a-button type="primary" block @click="onNav('/contact')">联系我们</a-button>
|
||||
<a-button block @click="onNav('/products')">经营范围</a-button>
|
||||
<a-button block @click="onNav('/join')">招商加盟</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mainNav } from '@/config/nav'
|
||||
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||
import { COMPANY } from '@/config/company'
|
||||
import { getToken } from '@/utils/token-util'
|
||||
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
const isAffixed = ref(false)
|
||||
const isHydrated = ref(false)
|
||||
const token = ref('')
|
||||
|
||||
const TOKEN_EVENT = 'auth-token-changed'
|
||||
const isLoggedIn = computed(() => isHydrated.value && !!token.value)
|
||||
|
||||
type HeaderNavItem = {
|
||||
key: string
|
||||
label: string
|
||||
to?: string
|
||||
href?: string
|
||||
target?: string
|
||||
children?: HeaderNavItem[]
|
||||
}
|
||||
|
||||
const { data: siteInfo } = useSiteInfo()
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const flatten = (items: HeaderNavItem[]) =>
|
||||
items.flatMap((i) => [i, ...(i.children?.length ? flatten(i.children) : [])])
|
||||
const all = flatten(navItems.value)
|
||||
|
||||
const exactHit = all.find((n) => n.to === route.path)
|
||||
if (exactHit) return [exactHit.key]
|
||||
|
||||
const prefixHit = all
|
||||
.filter((n) => n.to && n.to !== '/' && route.path.startsWith(n.to))
|
||||
.sort((a, b) => (b.to?.length ?? 0) - (a.to?.length ?? 0))[0]
|
||||
if (prefixHit) return [prefixHit.key]
|
||||
|
||||
const home = all.find((n) => n.to === '/')
|
||||
return home ? [home.key] : []
|
||||
})
|
||||
|
||||
type SiteInfoData = {
|
||||
websiteName?: unknown
|
||||
websiteLogo?: unknown
|
||||
websiteIcon?: unknown
|
||||
topNavs?: unknown
|
||||
setting?: unknown
|
||||
config?: unknown
|
||||
} & Record<string, unknown>
|
||||
|
||||
const siteData = computed<SiteInfoData | null>(() => {
|
||||
const data = siteInfo.value?.data
|
||||
if (data && typeof data === 'object') return data as SiteInfoData
|
||||
return null
|
||||
})
|
||||
|
||||
function pickString(source: unknown, key: string) {
|
||||
if (!source || typeof source !== 'object') return ''
|
||||
const record = source as Record<string, unknown>
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
const siteName = computed(() => {
|
||||
const websiteName = siteData.value?.websiteName
|
||||
return typeof websiteName === 'string' && websiteName.trim() ? websiteName.trim() : '桂乐淘'
|
||||
})
|
||||
|
||||
const showBrandbar = computed(() => {
|
||||
// Corporate site: keep brand bar consistent across pages.
|
||||
return true
|
||||
})
|
||||
|
||||
const logoUrl = computed(() => {
|
||||
const data = siteData.value
|
||||
const logo = typeof data?.websiteLogo === 'string' ? data.websiteLogo.trim() : ''
|
||||
const icon = typeof data?.websiteIcon === 'string' ? data.websiteIcon.trim() : ''
|
||||
return (
|
||||
logo ||
|
||||
icon ||
|
||||
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
|
||||
)
|
||||
})
|
||||
|
||||
const siteSlogan = computed(() => {
|
||||
const data = siteData.value
|
||||
const slogan =
|
||||
pickString(data?.setting, 'slogan') ||
|
||||
pickString(data?.setting, 'subtitle') ||
|
||||
pickString(data?.config, 'slogan')
|
||||
return slogan || `${COMPANY.projectName} · 品质服务与合规经营`
|
||||
})
|
||||
|
||||
const missionText = computed(() => '生物基材料研发 · 技术服务 · 食品与农产品流通')
|
||||
const valuesText = computed(() => '真诚 · 奉献 · 规范 · 聚力')
|
||||
|
||||
function normalizePath(path: unknown) {
|
||||
if (typeof path !== 'string') return ''
|
||||
const p = path.trim()
|
||||
if (!p) return ''
|
||||
if (/^https?:\/\//i.test(p)) return p
|
||||
if (p.startsWith('/')) return p
|
||||
return `/${p}`
|
||||
}
|
||||
|
||||
function normalizeNavTree(list: CmsNavigation[]): HeaderNavItem[] {
|
||||
const normalizeOne = (n: CmsNavigation): HeaderNavItem => {
|
||||
const label = String(n.title || n.label || '').trim() || '未命名'
|
||||
const rawPath = normalizePath(n.path)
|
||||
const isExternal = /^https?:\/\//i.test(rawPath)
|
||||
const children =
|
||||
Array.isArray(n.children) && n.children.length
|
||||
? n.children
|
||||
.slice()
|
||||
.filter((c) => (c.hide ?? 0) !== 1)
|
||||
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
|
||||
.map(normalizeOne)
|
||||
: undefined
|
||||
const key = String(n.code || n.navigationId || rawPath || label)
|
||||
const target = n.target ? String(n.target) : (isExternal ? '_blank' : undefined)
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
...(isExternal ? { href: rawPath } : { to: rawPath || '/' }),
|
||||
target,
|
||||
...(children?.length ? { children } : {})
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
.filter((n) => (n.hide ?? 0) !== 1)
|
||||
.slice()
|
||||
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
|
||||
.map(normalizeOne)
|
||||
}
|
||||
|
||||
const navItems = computed<HeaderNavItem[]>(() => {
|
||||
const apiNavs = siteData.value?.topNavs
|
||||
if (Array.isArray(apiNavs) && apiNavs.length) {
|
||||
// Prefer navigation from getShopInfo (CMS-managed).
|
||||
return normalizeNavTree(apiNavs as CmsNavigation[])
|
||||
}
|
||||
// Fallback when CMS has not configured topNavs.
|
||||
return mainNav.map((n) => ({ key: n.key || n.to, label: n.label, to: n.to }))
|
||||
})
|
||||
|
||||
function isActive(item: HeaderNavItem) {
|
||||
const isHit = (candidate: HeaderNavItem): boolean => {
|
||||
if (candidate.to && candidate.to === route.path) return true
|
||||
return !!candidate.children?.some(isHit)
|
||||
}
|
||||
return isHit(item)
|
||||
}
|
||||
|
||||
function onNavClick(item: HeaderNavItem) {
|
||||
open.value = false
|
||||
if (item.href) {
|
||||
window.open(item.href, item.target || '_blank')
|
||||
return
|
||||
}
|
||||
if (item.to) navigateTo(item.to)
|
||||
}
|
||||
|
||||
function onNav(to: string) {
|
||||
open.value = false
|
||||
navigateTo(to)
|
||||
}
|
||||
|
||||
const todayText = computed(() => {
|
||||
const d = new Date()
|
||||
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()] || ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}年${pad(d.getMonth() + 1)}月${pad(d.getDate())}日 星期${week}`
|
||||
})
|
||||
|
||||
function onAffixChange(affixed: boolean) {
|
||||
isAffixed.value = affixed
|
||||
}
|
||||
|
||||
function syncToken() {
|
||||
token.value = getToken()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isHydrated.value = true
|
||||
syncToken()
|
||||
window.addEventListener(TOKEN_EVENT, syncToken)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(TOKEN_EVENT, syncToken)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.brandbar {
|
||||
background:
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.88)),
|
||||
radial-gradient(circle at 25% 20%, rgba(220, 38, 38, 0.12), transparent 60%),
|
||||
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, #f2f4f7, #ffffff);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 62px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.brand-mission {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.brand-values {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #16a34a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
height: 48px;
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 700;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-brand-name {
|
||||
color: #e7ffe5;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 18px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.brandbar-inner {
|
||||
height: 160px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
app/components/console/ConsoleHeader.vue
Normal file
169
app/components/console/ConsoleHeader.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<a-layout-header class="top-header !bg-white !p-0">
|
||||
<div class="h-full px-4 flex items-center justify-between">
|
||||
<div class="logo">
|
||||
<a-space size="large">
|
||||
<a-image
|
||||
src="https://oss.wsdns.cn/20250204/a21e034cdb2441b797f5027db5305be5.png"
|
||||
class="mb-1"
|
||||
:width="90"
|
||||
:preview="false"
|
||||
@click="navigateTo('/console')"
|
||||
/>
|
||||
<!-- <a-dropdown-button @click="handleButtonClick">-->
|
||||
<!-- {{ productLabel }}-->
|
||||
<!-- <template #overlay>-->
|
||||
<!-- <a-menu @click="handleProductMenuClick">-->
|
||||
<!-- <a-menu-item key="site">-->
|
||||
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250215/2016c6f2da074b09b11a0e3603f5be23.png" />-->
|
||||
<!-- 云·企业官网-->
|
||||
<!-- </a-menu-item>-->
|
||||
<!-- <a-menu-item key="mp">-->
|
||||
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250304/e65ea719564e47a1b8da93d6eea8287a.png" />-->
|
||||
<!-- 小程序开发-->
|
||||
<!-- </a-menu-item>-->
|
||||
<!-- <a-menu-item key="oa">-->
|
||||
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250215/457a343dba204d019281d8a23556c4b1.png" />-->
|
||||
<!-- 办公协同OA-->
|
||||
<!-- </a-menu-item>-->
|
||||
<!-- <a-menu-item key="developer">-->
|
||||
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250214/d455a32a7a2043d899e079b4eb9b27b8.png" />-->
|
||||
<!-- 开发者中心-->
|
||||
<!-- </a-menu-item>-->
|
||||
<!-- </a-menu>-->
|
||||
<!-- </template>-->
|
||||
<!-- </a-dropdown-button>-->
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-dropdown placement="bottomRight" :trigger="['click']">
|
||||
<div class="user-trigger">
|
||||
<a-space>
|
||||
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
|
||||
<template #icon>
|
||||
<AppstoreOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="user-name">
|
||||
{{ userDisplayName }}
|
||||
</span>
|
||||
</a-space>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="onUserMenuClick">
|
||||
<a-menu-item v-for="item in mergedUserMenuItems" :key="item.key">
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppstoreOutlined } from '@ant-design/icons-vue'
|
||||
import type { MenuProps } from 'ant-design-vue'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
type ConsoleHeaderMenuItem = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
productLabel?: string
|
||||
defaultJumpKey?: 'site' | 'mp' | 'oa' | 'developer'
|
||||
user: User | null
|
||||
userDisplayName: string
|
||||
userMenuItems?: ConsoleHeaderMenuItem[]
|
||||
}>(),
|
||||
{
|
||||
productLabel: '云·企业官网',
|
||||
defaultJumpKey: 'site',
|
||||
userMenuItems: () => [{ key: 'logout', label: '退出登录' }],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'logout'): void
|
||||
(e: 'userMenuClick', key: string): void
|
||||
}>()
|
||||
|
||||
const mergedUserMenuItems = computed<ConsoleHeaderMenuItem[]>(() => {
|
||||
const items = Array.isArray(props.userMenuItems) ? props.userMenuItems.slice() : []
|
||||
if (!items.some((i) => i.key === 'account')) {
|
||||
const accountItem: ConsoleHeaderMenuItem = { key: 'account', label: '账号管理' }
|
||||
const logoutIndex = items.findIndex((i) => i.key === 'logout')
|
||||
if (logoutIndex >= 0) items.splice(logoutIndex, 0, accountItem)
|
||||
else items.push(accountItem)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const consoleJumpTargets = {
|
||||
site: '/site',
|
||||
mp: '/mp',
|
||||
oa: '/oa',
|
||||
developer: '/developer',
|
||||
} as const
|
||||
|
||||
function openExternal(url: string) {
|
||||
if (!import.meta.client) return
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
void navigateTo(url)
|
||||
}
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openExternal(consoleJumpTargets[props.defaultJumpKey])
|
||||
}
|
||||
|
||||
const handleProductMenuClick: MenuProps['onClick'] = (e) => {
|
||||
const key = String(e.key) as keyof typeof consoleJumpTargets
|
||||
const url = consoleJumpTargets[key]
|
||||
if (!url) return
|
||||
openExternal(url)
|
||||
}
|
||||
|
||||
function onUserMenuClick(info: { key: string }) {
|
||||
const key = String(info.key)
|
||||
emit('userMenuClick', key)
|
||||
if (key === 'account') {
|
||||
void navigateTo('/console/account')
|
||||
return
|
||||
}
|
||||
if (key === 'logout') emit('logout')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-header {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
156
app/components/developer/AppsCenter.vue
Normal file
156
app/components/developer/AppsCenter.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="应用列表" class="shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
placeholder="关键词 keywords"
|
||||
class="w-72"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
|
||||
<a-button type="primary" :loading="pending" @click="doSearch">查询</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
class="mt-4"
|
||||
show-icon
|
||||
type="error"
|
||||
:message="String(error)"
|
||||
/>
|
||||
|
||||
<a-table
|
||||
class="mt-4"
|
||||
:data-source="list"
|
||||
:loading="pending"
|
||||
:pagination="false"
|
||||
row-key="websiteId"
|
||||
size="middle"
|
||||
>
|
||||
<a-table-column title="ID" data-index="tenantId" width="90" />
|
||||
<a-table-column title="应用名称" data-index="websiteName" />
|
||||
<a-table-column title="标识" data-index="websiteCode" width="200" />
|
||||
<a-table-column title="域名" data-index="domain" width="220" />
|
||||
|
||||
<a-table-column title="类型" width="120">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="record.plugin ? 'geekblue' : 'green'">
|
||||
{{ record.plugin ? '插件' : '应用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" width="140">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">
|
||||
{{ statusText(record.status, record.statusText) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="到期时间" data-index="expirationTime" width="180" />
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180" />
|
||||
|
||||
<a-table-column title="后台" width="220">
|
||||
<template #default="{ record }">
|
||||
<a
|
||||
v-if="record.adminUrl"
|
||||
class="text-blue-600 hover:underline"
|
||||
:href="record.adminUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ record.adminUrl }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { pageCmsWebsiteAll } from '@/api/cms/cmsWebsite/index'
|
||||
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '应用中心',
|
||||
description: '使用 pageCmsWebsite 分页读取应用并展示。'
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keywords = ref('')
|
||||
|
||||
const { data, pending, error, refresh } = useAsyncData(
|
||||
'developer-cms-website-page',
|
||||
() =>
|
||||
pageCmsWebsiteAll({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined
|
||||
}),
|
||||
{ server: false }
|
||||
)
|
||||
|
||||
const list = computed<CmsWebsite[]>(() => data.value?.list ?? [])
|
||||
const total = computed(() => data.value?.count ?? 0)
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
refresh()
|
||||
}
|
||||
|
||||
function statusText(status?: number, fallback?: string) {
|
||||
if (fallback) return fallback
|
||||
const map: Record<number, string> = {
|
||||
0: '未开通',
|
||||
1: '运行中',
|
||||
2: '维护中',
|
||||
3: '已关闭',
|
||||
4: '欠费停机',
|
||||
5: '违规关停'
|
||||
}
|
||||
if (typeof status === 'number' && status in map) return map[status]
|
||||
return '-'
|
||||
}
|
||||
|
||||
function statusColor(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'default',
|
||||
1: 'green',
|
||||
2: 'orange',
|
||||
3: 'red',
|
||||
4: 'volcano',
|
||||
5: 'red'
|
||||
}
|
||||
if (typeof status === 'number' && status in map) return map[status]
|
||||
return 'default'
|
||||
}
|
||||
</script>
|
||||
444
app/components/shop/GoodsCategoryPage.vue
Normal file
444
app/components/shop/GoodsCategoryPage.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<main class="goods-page">
|
||||
<section class="goods-hero" :style="heroStyle">
|
||||
<div class="goods-hero-mask">
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-8">
|
||||
<a-breadcrumb class="goods-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<NuxtLink to="/">首页</NuxtLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>商品</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
|
||||
<div class="goods-hero-title">{{ pageTitle }}</div>
|
||||
|
||||
<div class="goods-hero-meta">
|
||||
<a-tag v-if="typeof total === 'number'" color="green">共 {{ total }} 件</a-tag>
|
||||
<a-tag v-if="keywords" color="blue">关键词:{{ keywords }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<a-input
|
||||
v-model:value="keywordInput"
|
||||
allow-clear
|
||||
class="w-full sm:w-80"
|
||||
placeholder="搜索商品名称/关键词"
|
||||
@press-enter="applySearch"
|
||||
/>
|
||||
<a-button type="primary" @click="applySearch">搜索</a-button>
|
||||
<a-button v-if="keywords" @click="clearSearch">清除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-screen-xl px-4 py-10">
|
||||
<a-card class="goods-card" :bordered="false">
|
||||
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
|
||||
|
||||
<a-result
|
||||
v-else-if="!isValidCategoryId"
|
||||
status="404"
|
||||
title="分类不存在"
|
||||
sub-title="分类ID无效或缺失。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
|
||||
<a-result
|
||||
v-else-if="loadError"
|
||||
status="error"
|
||||
title="商品加载失败"
|
||||
:sub-title="loadError.message"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="refresh()">重试</a-button>
|
||||
<a-button @click="navigateTo('/')">返回首页</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
|
||||
<template v-else>
|
||||
<a-empty v-if="!goodsList.length" description="暂无商品" />
|
||||
|
||||
<div v-else class="grid grid-cols-12 gap-6">
|
||||
<a-card
|
||||
v-for="(g, idx) in goodsList"
|
||||
:key="String(g.goodsId ?? g.code ?? `${String(route.params.navigationId)}-${idx}`)"
|
||||
hoverable
|
||||
class="col-span-12 sm:col-span-6 lg:col-span-3 goods-item"
|
||||
@click="goDetail(g)"
|
||||
>
|
||||
<template #cover>
|
||||
<img
|
||||
class="goods-cover"
|
||||
:src="resolveGoodsImage(g)"
|
||||
:alt="resolveGoodsTitle(g)"
|
||||
loading="lazy"
|
||||
@error="onImgError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<a-card-meta :title="resolveGoodsTitle(g)">
|
||||
<template #description>
|
||||
<div class="goods-desc">
|
||||
<div class="goods-price-row">
|
||||
<span class="goods-price">
|
||||
{{ formatMoney(resolveGoodsPrice(g)) }}
|
||||
<span v-if="resolveGoodsUnit(g)" class="goods-unit">/ {{ resolveGoodsUnit(g) }}</span>
|
||||
</span>
|
||||
<a-tag v-if="Number(g.isNew) === 1" color="blue">新品</a-tag>
|
||||
<a-tag v-if="Number(g.recommend) === 1" color="green">推荐</a-tag>
|
||||
</div>
|
||||
<div class="goods-sub">
|
||||
<span v-if="typeof g.sales === 'number'">销量 {{ g.sales }}</span>
|
||||
<span v-if="typeof g.stock === 'number'">库存 {{ g.stock }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-card-meta>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="mt-6 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '48', '96']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { pageShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
||||
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function parseNumberParam(raw: unknown): number {
|
||||
const text = Array.isArray(raw) ? raw[0] : raw
|
||||
const n = Number(text)
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: unknown, fallback: number) {
|
||||
const text = Array.isArray(raw) ? raw[0] : raw
|
||||
const n = Number(text)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
const i = Math.floor(n)
|
||||
return i > 0 ? i : fallback
|
||||
}
|
||||
|
||||
function parseQueryString(raw: unknown) {
|
||||
const text = Array.isArray(raw) ? raw[0] : raw
|
||||
return typeof text === 'string' ? text.trim() : ''
|
||||
}
|
||||
|
||||
const categoryId = computed(() => parseNumberParam(route.params.navigationId))
|
||||
const isValidCategoryId = computed(() => Number.isFinite(categoryId.value) && categoryId.value > 0)
|
||||
|
||||
const page = computed(() => parsePositiveInt(route.query.page, 1))
|
||||
const limit = computed(() => parsePositiveInt(route.query.limit, 12))
|
||||
const keywords = computed(() => parseQueryString(route.query.q))
|
||||
|
||||
const keywordInput = ref(keywords.value)
|
||||
watch(
|
||||
() => keywords.value,
|
||||
(v) => {
|
||||
keywordInput.value = v
|
||||
}
|
||||
)
|
||||
|
||||
function updateQuery(next: LocationQueryRaw) {
|
||||
// Keep list state shareable via URL.
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: {
|
||||
...route.query,
|
||||
...next
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
updateQuery({ q: keywordInput.value?.trim() || undefined, page: 1 })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keywordInput.value = ''
|
||||
updateQuery({ q: undefined, page: 1 })
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
updateQuery({ page: nextPage })
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
updateQuery({ limit: nextSize, page: 1 })
|
||||
}
|
||||
|
||||
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
|
||||
() => `cms-navigation-${String(route.params.navigationId)}`,
|
||||
async () => {
|
||||
if (!isValidCategoryId.value) return null
|
||||
return await getCmsNavigation(categoryId.value).catch(() => null)
|
||||
},
|
||||
{ watch: [categoryId] }
|
||||
)
|
||||
|
||||
const {
|
||||
data: goodsPage,
|
||||
pending,
|
||||
error: loadError,
|
||||
refresh
|
||||
} = await useAsyncData<{ list: ShopGoods[]; count: number } | null>(
|
||||
() =>
|
||||
`shop-goods-${String(route.params.navigationId)}-${page.value}-${limit.value}-${keywords.value}`,
|
||||
async () => {
|
||||
if (!isValidCategoryId.value) return null
|
||||
return await pageShopGoods({
|
||||
categoryId: categoryId.value,
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined
|
||||
})
|
||||
},
|
||||
{ watch: [categoryId, page, limit, keywords] }
|
||||
)
|
||||
|
||||
const goodsList = computed(() => goodsPage.value?.list ?? [])
|
||||
const total = computed(() => goodsPage.value?.count ?? 0)
|
||||
|
||||
function goDetail(g: ShopGoods) {
|
||||
const id = (g as unknown as { goodsId?: unknown }).goodsId
|
||||
const n = typeof id === 'number' ? id : Number(id)
|
||||
if (!Number.isFinite(n)) return
|
||||
navigateTo(`/goods-item/${n}`)
|
||||
}
|
||||
|
||||
function pickString(obj: unknown, key: string) {
|
||||
if (!obj || typeof obj !== 'object') return ''
|
||||
const record = obj as Record<string, unknown>
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||
const name = nav ? pickString(nav, 'title') || pickString(nav, 'label') : ''
|
||||
if (name) return name
|
||||
if (isValidCategoryId.value) return `分类 ${categoryId.value}`
|
||||
return '商品列表'
|
||||
})
|
||||
|
||||
const heroStyle = computed(() => {
|
||||
const nav = navigation.value as unknown as Record<string, unknown> | null
|
||||
const banner = nav ? (pickString(nav, 'banner') || pickString(nav, 'image')) : ''
|
||||
if (banner) {
|
||||
return {
|
||||
backgroundImage: `url(${banner})`
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
function resolveGoodsTitle(g: ShopGoods) {
|
||||
return String(g.goodsName || g.name || g.code || '未命名商品').trim()
|
||||
}
|
||||
|
||||
function resolveGoodsPrice(g: ShopGoods) {
|
||||
const anyG = g as unknown as Record<string, unknown>
|
||||
const candidates = [
|
||||
anyG.salePrice,
|
||||
anyG.price,
|
||||
anyG.chainStorePrice,
|
||||
anyG.originPrice,
|
||||
anyG.buyingPrice
|
||||
]
|
||||
for (const v of candidates) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveGoodsUnit(g: ShopGoods) {
|
||||
const unit = (g as unknown as { unitName?: unknown }).unitName
|
||||
if (typeof unit === 'string' && unit.trim()) return unit.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveGoodsImage(g: ShopGoods) {
|
||||
const img = typeof g.image === 'string' ? g.image.trim() : ''
|
||||
if (img) return img
|
||||
const files = typeof g.files === 'string' ? g.files.trim() : ''
|
||||
if (files) {
|
||||
// Some APIs store files as JSON array string: [{ url: "..." }, ...]
|
||||
if (files.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(files) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const first = parsed[0] as unknown
|
||||
const rec = first && typeof first === 'object' ? (first as Record<string, unknown>) : null
|
||||
const url = rec && typeof rec.url === 'string' ? rec.url.trim() : ''
|
||||
if (url) return url
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
return files.split(',')[0]?.trim() || ''
|
||||
}
|
||||
// Keep it empty -> browser will trigger @error and swap to placeholder.
|
||||
return ''
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
const img = e.target as HTMLImageElement | null
|
||||
if (!img) return
|
||||
img.onerror = null
|
||||
img.src =
|
||||
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return `¥${value.toFixed(2)}`
|
||||
const v = typeof value === 'string' ? value.trim() : ''
|
||||
if (!v) return '-'
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return `¥${v}`
|
||||
return `¥${n.toFixed(2)}`
|
||||
}
|
||||
|
||||
const seoTitle = computed(() => `${pageTitle.value} - 商品`)
|
||||
const seoDescription = computed(() => {
|
||||
if (!isValidCategoryId.value) return '商品分类列表'
|
||||
const suffix = keywords.value ? `,关键词:${keywords.value}` : ''
|
||||
return `分类 ${pageTitle.value} 商品列表${suffix}`
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
ogTitle: seoTitle,
|
||||
ogDescription: seoDescription,
|
||||
ogType: 'website'
|
||||
})
|
||||
|
||||
const canonicalUrl = computed(() => {
|
||||
if (import.meta.client) return window.location.href
|
||||
try {
|
||||
return useRequestURL().href
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
link: canonicalUrl.value ? [{ rel: 'canonical', href: canonicalUrl.value }] : []
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goods-page {
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.goods-hero {
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
|
||||
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
|
||||
linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.goods-hero-mask {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
|
||||
}
|
||||
|
||||
.goods-breadcrumb {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.goods-hero-title {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.goods-hero-meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.goods-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.goods-cover {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.goods-desc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.goods-price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.goods-price {
|
||||
color: #16a34a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.goods-unit {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.goods-sub {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user