MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)

This commit is contained in:
Sakurasan
2026-09-20 20:56:03 +08:00
parent edee708802
commit 4d8f2de3a4
96 changed files with 6005 additions and 2602 deletions
+159
View File
@@ -0,0 +1,159 @@
<script setup>
import { site } from '../site'
import { publicApi } from '../api'
import { onMounted, ref } from 'vue'
const tags = ref([])
onMounted(async () => {
try {
const data = await publicApi.tags()
tags.value = (data.tags || []).slice(0, 8)
} catch (e) {
tags.value = []
}
})
</script>
<template>
<aside class="rail-left">
<div class="inner">
<RouterLink to="/" class="brand">
<span class="mark">○</span>
<span class="name">{{ site.site_title || 'ONE' }}</span>
</RouterLink>
<p class="desc">{{ site.site_desc }}</p>
<nav class="nav">
<RouterLink to="/" class="item">
<span class="ico">◴</span><span>时间线</span>
</RouterLink>
<RouterLink to="/archive" class="item">
<span class="ico">▤</span><span>归档</span>
</RouterLink>
<RouterLink to="/tags" class="item">
<span class="ico">#</span><span>标签</span>
</RouterLink>
<RouterLink to="/about" class="item">
<span class="ico">◍</span><span>关于</span>
</RouterLink>
</nav>
<a class="subscribe" href="/rss.xml">订阅 RSS</a>
<div v-if="tags.length" class="tagbox">
<p class="eyebrow">常读标签</p>
<div class="tags">
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
{{ t.name }}
</RouterLink>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.rail-left {
position: sticky;
top: 0;
align-self: start;
padding: 28px 0 40px;
}
.inner {
display: flex;
flex-direction: column;
gap: 16px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.mark {
color: var(--accent);
font-size: 18px;
}
.name {
font-family: var(--serif);
font-size: 21px;
letter-spacing: 0.02em;
}
.desc {
margin: -6px 0 0;
font-size: 13px;
line-height: 1.75;
color: var(--muted);
}
.nav {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
margin-left: -10px;
border-radius: 3px;
font-size: 15px;
color: var(--ink-soft);
}
.item:hover {
background: var(--paper-sunken);
color: var(--ink);
}
.item.router-link-exact-active {
color: var(--accent);
font-weight: 500;
}
.ico {
width: 16px;
text-align: center;
color: var(--faint);
font-size: 13px;
}
.item.router-link-exact-active .ico {
color: var(--accent);
}
.subscribe {
align-self: flex-start;
padding: 7px 20px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 14px;
border: 1px solid var(--accent);
}
.subscribe:hover {
background: #356f88;
color: #fff;
}
.tagbox {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import { relativeDate, stripTags, minutesLabel } from '../utils'
const props = defineProps({
post: { type: Object, required: true }
})
const isShort = computed(() => props.post.kind === 'short')
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
return props.post.content_html || ''
})
// 长文没写摘要时,从正文里截一段纯文本
const summaryText = computed(() => {
const text = props.post.summary || stripTags(props.post.content_html || '')
return text.length > 110 ? text.slice(0, 110) + '…' : text
})
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
</script>
<template>
<article class="row-feed" :class="{ short: isShort }">
<div class="avatar" aria-hidden="true">{{ initial }}</div>
<div class="body">
<div class="byline">
<span class="author">{{ site.author_name || 'ONE' }}</span>
<span class="handle">@oneblog</span>
<span class="dot">·</span>
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
<span v-if="isShort" class="kind">短文</span>
<span v-if="post.status === 'draft'" class="kind draft">草稿</span>
</div>
<RouterLink v-if="!isShort" :to="`/post/${post.slug}`" class="title-link">
<h2 class="title">{{ post.title || '无题' }}</h2>
</RouterLink>
<!-- 长文:摘要;短文:正文直接铺开 -->
<div v-if="isShort" class="prose short-body" v-html="body"></div>
<div v-else class="summary">{{ summaryText }}</div>
<div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read">
{{ isShort ? '查看' : '继续阅读' }} →
</RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags">
<RouterLink
v-for="t in post.tags"
:key="t"
:to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip"
>
{{ t }}
</RouterLink>
</span>
</div>
</div>
</article>
</template>
<style scoped>
.row-feed {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
padding: 18px 16px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
transition: background 0.15s ease;
}
.row-feed:hover {
background: var(--paper-sunken);
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: 17px;
user-select: none;
}
.byline {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 5px;
font-size: 13px;
color: var(--muted);
}
.author {
font-weight: 600;
color: var(--ink);
}
.handle,
.dot {
color: var(--faint);
}
.kind {
padding: 0 5px;
border: 1px solid var(--line);
border-radius: 2px;
font-size: 11px;
color: var(--muted);
}
.kind.draft {
color: #9a5b45;
border-color: #ddcdc2;
}
.title-link {
display: block;
}
.title {
margin: 6px 0 4px;
font-size: 21px;
line-height: 1.5;
}
.summary {
color: var(--ink-soft);
font-size: 15px;
line-height: 1.85;
}
.short-body {
margin-top: 6px;
font-size: 16.5px;
}
.short-body :deep(p) {
margin: 0;
}
.meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
font-size: 12.5px;
color: var(--faint);
}
.read {
color: var(--accent);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 520px) {
.row-feed {
grid-template-columns: 34px minmax(0, 1fr);
padding: 16px 12px;
margin: 0 -12px;
}
.avatar {
width: 34px;
height: 34px;
font-size: 15px;
}
.title {
font-size: 19px;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { onMounted, ref } from 'vue'
import { publicApi } from '../api'
import { site } from '../site'
import { relativeDate } from '../utils'
const latest = ref([])
const tags = ref([])
onMounted(async () => {
try {
const [posts, tagData] = await Promise.all([
publicApi.posts({ size: 5 }),
publicApi.tags()
])
latest.value = posts.items || []
tags.value = (tagData.tags || []).slice(0, 12)
} catch (e) {
latest.value = []
tags.value = []
}
})
</script>
<template>
<aside class="rail-right">
<section class="card">
<p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
<RouterLink to="/about" class="more">了解更多 →</RouterLink>
</section>
<section v-if="latest.length" class="card">
<p class="eyebrow">最近更新</p>
<ul class="list">
<li v-for="p in latest" :key="p.id">
<RouterLink :to="`/post/${p.slug}`" class="row">
<span class="title">
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
<span v-if="p.kind === 'short'" class="badge">短</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
</RouterLink>
</li>
</ul>
</section>
<section v-if="tags.length" class="card">
<p class="eyebrow">标签</p>
<div class="tags">
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
{{ t.name }}<span class="count"> {{ t.count }}</span>
</RouterLink>
</div>
</section>
<section class="card">
<p class="eyebrow">订阅</p>
<p class="bio">用 RSS 阅读器跟进更新。</p>
<a class="pill pill-ghost" href="/rss.xml">RSS / feed.xml</a>
</section>
</aside>
</template>
<style scoped>
.rail-right {
position: sticky;
top: 0;
align-self: start;
padding: 28px 0 40px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
padding: 16px 18px;
}
.bio {
margin: 8px 0 10px;
font-size: 13.5px;
line-height: 1.85;
color: var(--ink-soft);
}
.more {
font-size: 13px;
color: var(--accent);
}
.list {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.row {
display: block;
padding: 7px 0;
border-bottom: 1px dashed var(--line-soft);
}
.list li:last-child .row {
border-bottom: 0;
}
.title {
display: block;
font-size: 14px;
line-height: 1.7;
}
.badge {
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border: 1px solid var(--line);
border-radius: 2px;
font-size: 11px;
color: var(--muted);
vertical-align: 1px;
}
.date {
font-size: 12px;
color: var(--faint);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.count {
color: var(--faint);
font-size: 11px;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { site } from '../site'
</script>
<template>
<!-- 窄屏时左栏收起,导航回到顶部 -->
<header class="topbar">
<RouterLink to="/" class="brand">{{ site.site_title || 'ONE' }}</RouterLink>
<nav class="links">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/archive">归档</RouterLink>
<RouterLink to="/tags">标签</RouterLink>
<RouterLink to="/about">关于</RouterLink>
</nav>
</header>
</template>
<style scoped>
.topbar {
display: none;
}
@media (max-width: 780px) {
.topbar {
display: flex;
align-items: baseline;
gap: 14px;
position: sticky;
top: 0;
z-index: 20;
padding: 10px 18px;
background: rgba(250, 247, 241, 0.94);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.brand {
font-family: var(--serif);
font-size: 17px;
}
.links {
display: flex;
gap: 12px;
font-size: 13px;
color: var(--muted);
}
.links a.router-link-exact-active {
color: var(--accent);
}
}
</style>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{ src?: string; name?: string; size?: number }>(),
{ size: 40, name: '' },
)
const failed = ref(false)
const initial = computed(() => (props.name || '?').slice(0, 1).toUpperCase())
const sizeStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}))
</script>
<template>
<span class="inline-block rounded-full overflow-hidden shrink-0 bg-zinc-100" :style="sizeStyle">
<img
v-if="src && !failed"
:src="src"
:alt="name"
class="size-full object-cover"
loading="lazy"
@error="failed = true"
/>
<span
v-else
class="size-full flex items-center justify-center bg-accent text-white font-medium select-none"
:style="{ fontSize: `${Math.round(size * 0.42)}px` }"
>
{{ initial }}
</span>
</span>
</template>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { dark, toggle } = useTheme()
</script>
<template>
<button
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
:aria-label="dark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggle"
>
<i :class="dark ? 'i-mingcute-moon-line' : 'i-mingcute-sun-line'" class="text-xl" />
</button>
</template>
@@ -1,16 +0,0 @@
<script setup lang="ts">
withDefaults(
defineProps<{ icon?: string; text?: string }>(),
{
icon: 'i-mingcute-inbox-line',
text: 'No posts yet',
},
)
</script>
<template>
<div class="flex flex-col items-center justify-center py-20 text-zinc-400">
<i :class="icon" class="text-6xl mb-4" />
<span>{{ text }}</span>
</div>
</template>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatNumber } from '@/lib/utils'
const props = defineProps<{ value: number }>()
const text = computed(() => formatNumber(props.value))
</script>
<template>
<span>{{ text }}</span>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const locales = ['en', 'zh'] as const
type Locale = (typeof locales)[number]
const locale = ref<Locale>('en')
function toggle() {
locale.value = locale.value === 'en' ? 'zh' : 'en'
document.documentElement.lang = locale.value
}
</script>
<template>
<button
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
aria-label="Switch language"
@click="toggle"
>
<i class="i-mingcute-translate-2-line text-xl" />
<span class="ml-1 text-xs uppercase">{{ locale }}</span>
</button>
</template>
@@ -1,10 +0,0 @@
<script setup lang="ts">
withDefaults(defineProps<{ text?: string }>(), { text: 'Loading...' })
</script>
<template>
<div class="flex flex-col items-center justify-center py-20 text-zinc-400 space-y-3">
<i class="i-mingcute-loading-3-line text-3xl animate-spin" />
<span class="text-sm">{{ text }}</span>
</div>
</template>
-59
View File
@@ -1,59 +0,0 @@
<script setup lang="ts">
// xLog Logo:内联 SVG,保留原始渐变与暗色适配
import { computed } from 'vue'
const props = withDefaults(defineProps<{ size?: number }>(), { size: 36 })
const gradientId = `xlog-gradient-${Math.random().toString(36).slice(2, 8)}`
const style = computed(() => ({ width: `${props.size}px`, height: `${props.size}px` }))
</script>
<template>
<svg
:style="style"
viewBox="0 0 128.81 128.17"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient
:id="gradientId"
x1="57.54"
y1="31.44"
x2="128.81"
y2="31.44"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#ff4d4d" />
<stop offset=".99" stop-color="#f9cb28" />
</linearGradient>
</defs>
<path
class="xlog-logo-dark"
d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"
/>
<path
class="xlog-logo-dark"
d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"
/>
<polygon
:fill="`url(#${gradientId})`"
points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"
/>
<polygon
class="xlog-logo-dark"
points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"
/>
</svg>
</template>
<style>
.xlog-logo-dark {
fill: #000;
}
@media (prefers-color-scheme: dark) {
.xlog-logo-dark {
fill: #fff;
}
}
</style>
@@ -1,19 +0,0 @@
<script setup lang="ts">
// Markdown 渲染:marked 解析 + DOMPurify 消毒,输出到 .prose 容器
import { computed } from 'vue'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
const props = defineProps<{ content: string }>()
marked.setOptions({ gfm: true, breaks: true })
const html = computed(() => {
const raw = marked.parse(props.content, { async: false }) as string
return DOMPurify.sanitize(raw)
})
</script>
<template>
<div class="prose" v-html="html" />
</template>
@@ -1,91 +0,0 @@
<script setup lang="ts">
// 文章卡片:整体为一个链接,封面 + 标题/摘要 + 元信息 + 作者
import { computed } from 'vue'
import type { Post } from '@/mock/data'
import { estimateReadingTime } from '@/lib/utils'
import Avatar from './Avatar.vue'
import FormattedNumber from './FormattedNumber.vue'
import Time from './Time.vue'
const props = defineProps<{ post: Post; isShort?: boolean }>()
const readingTime = computed(() => estimateReadingTime(props.post.content))
</script>
<template>
<RouterLink
:to="`/post/${post.siteId}/${post.slug}`"
class="xlog-post rounded-2xl flex flex-col items-center group relative border sm:hover:bg-hover transition-all hover:opacity-100"
>
<!-- 置顶标记 -->
<span
v-if="post.pinned"
class="absolute top-2 right-2 z-10 text-xs border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px]"
>
<i class="i-mingcute-pin-2-fill mr-1" />
Pinned
</span>
<!-- 封面图 -->
<div class="xlog-post-cover rounded-t-2xl overflow-hidden flex items-center relative w-full aspect-video border-b">
<img
:src="post.cover"
:alt="post.title"
class="object-cover size-full sm:group-hover:scale-105 sm:transition-transform sm:duration-400 sm:ease-in-out bg-white"
loading="lazy"
/>
</div>
<!-- 内容区 -->
<div class="px-3 py-2 w-full min-w-0 flex flex-col text-sm space-y-2 sm:px-5 sm:py-4 h-auto sm:h-[163px]">
<!-- 标题 + 摘要 -->
<div class="space-y-2 line-clamp-3 h-[75px]">
<h2 class="xlog-post-title font-bold text-zinc-700 text-base">
{{ post.title }}
</h2>
<div class="xlog-post-excerpt text-zinc-500 line-clamp-3" style="word-break: break-word">
{{ post.excerpt }}
</div>
</div>
<!-- 底部元信息 -->
<div class="xlog-post-meta text-zinc-400 flex items-center text-[13px] truncate space-x-2">
<span
v-if="post.tags[0]"
class="hover:text-zinc-600 hover:bg-zinc-200 border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs sm:text-[13px] h-5"
>
<i class="i-mingcute-tag-line mr-[2px]" />
{{ post.tags[0] }}
</span>
<span class="xlog-post-views inline-flex items-center">
<i class="i-mingcute-eye-line mr-[2px] text-base" />
<FormattedNumber :value="post.views" />
</span>
<span class="xlog-post-views inline-flex items-center">
<i class="i-mingcute-comment-line mr-[2px] text-base" />
<FormattedNumber :value="post.comments" />
</span>
<span class="xlog-post-word-count sm:inline-flex items-center hidden">
<i class="i-mingcute-sandglass-line mr-[2px] text-sm" />
<span style="word-spacing: -.2ch">{{ readingTime }} min</span>
</span>
</div>
<!-- 作者 + 时间 -->
<div class="flex items-center space-x-1 text-xs sm:text-sm overflow-hidden">
<span class="flex items-center cursor-pointer">
<span class="size-5 inline-block mr-[6px]">
<Avatar :src="post.author.avatar" :name="post.author.name" :size="20" />
</span>
<span class="font-medium truncate text-zinc-600">{{ post.author.name }}</span>
</span>
<span class="text-zinc-400 hidden sm:inline-block">·</span>
<time class="xlog-post-date whitespace-nowrap text-zinc-400 hidden sm:inline-block">
<Time :date="post.date" />
</time>
</div>
</div>
</RouterLink>
</template>
@@ -1,27 +0,0 @@
<script setup lang="ts">
// 文章卡片骨架屏(配合 HomeFeed 的 grid 使用)
withDefaults(defineProps<{ count?: number }>(), { count: 6 })
</script>
<template>
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
<div
v-for="i in count"
:key="i"
class="rounded-2xl border animate-pulse"
>
<!-- 封面图骨架 -->
<div class="h-auto rounded-t-2xl rounded-b-none w-full aspect-video border-b bg-gray-100" />
<!-- 内容骨架 -->
<div class="rounded-t-none rounded-b-2xl p-3 pt-2 sm:p-5 sm:pt-4 h-[168px] sm:h-[204px]">
<div class="flex items-center space-x-1 sm:space-x-2 mb-2 sm:mb-4 text-xs sm:text-sm">
<span class="flex items-center space-x-1 sm:space-x-2">
<span class="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-gray-100 block" />
<span class="w-[120px] h-5 bg-gray-100 block rounded" />
</span>
</div>
<span class="w-full h-28 bg-gray-100 block rounded" />
</div>
</div>
</div>
</template>
-13
View File
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatTime } from '@/lib/utils'
const props = defineProps<{ date: string | Date }>()
const text = computed(() => formatTime(props.date))
</script>
<template>
<time :datetime="typeof date === 'string' ? date : date.toISOString()">{{ text }}</time>
</template>
@@ -1,14 +0,0 @@
<script setup lang="ts">
// 仪表盘主内容容器
defineProps<{ title?: string }>()
</script>
<template>
<div
class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col bg-white"
id="dashboard-main"
>
<h2 v-if="title" class="text-2xl font-bold mb-8">{{ title }}</h2>
<slot />
</div>
</template>
@@ -1,108 +0,0 @@
<script setup lang="ts">
// 仪表盘侧边栏:Logo + 连接钱包 + 导航 + 底部帮助链接
import { useRoute } from 'vue-router'
import Logo from '@/components/common/Logo.vue'
const route = useRoute()
const links = [
{
key: 'dashboard',
label: 'Dashboard',
to: '/dashboard',
icon: 'i-mingcute-grid-line',
level: 0,
},
{
key: 'posts',
label: 'Posts',
to: '/dashboard/posts',
icon: 'i-mingcute-news-line',
level: 1,
},
{
key: 'pages',
label: 'Pages',
to: '/dashboard/pages',
icon: 'i-mingcute-file-line',
level: 1,
},
{
key: 'settings',
label: 'Settings',
to: '/dashboard/settings/general',
icon: 'i-mingcute-settings-3-line',
level: 1,
},
]
function isActive(link: { key: string }) {
switch (link.key) {
case 'dashboard':
return route.path === '/dashboard'
case 'posts':
return route.path.startsWith('/dashboard/posts')
case 'pages':
return route.path.startsWith('/dashboard/pages')
case 'settings':
return route.path.startsWith('/dashboard/settings')
default:
return false
}
}
</script>
<template>
<div class="w-sidebar fixed h-full flex flex-col bg-slate-50">
<!-- Logo -->
<RouterLink to="/" class="mb-2 px-5 pt-3 pb-2 text-2xl font-extrabold flex items-center">
<div class="inline-block size-9 mr-3">
<Logo :size="36" />
</div>
MyBlog
</RouterLink>
<!-- 连接钱包 -->
<div class="mb-2 px-2 pt-3 pb-2">
<button class="button is-primary is-block">Connect Wallet</button>
</div>
<!-- 导航链接 -->
<div class="px-3 space-y-[2px] text-zinc-500 flex-1 min-h-0 overflow-y-auto">
<RouterLink
v-for="link in links"
:key="link.key"
:to="link.to"
class="flex px-4 h-12 items-center rounded-xl space-x-2 w-full transition-colors"
:class="
isActive(link)
? 'bg-white font-medium text-accent drop-shadow-sm'
: 'hover:bg-slate-200/50'
"
:style="{ marginLeft: `${link.level * 20}px` }"
>
<i :class="link.icon" class="text-xl" />
<span class="truncate">{{ link.label }}</span>
</RouterLink>
</div>
<!-- 底部固定按钮 -->
<div class="flex items-center px-4 flex-col pb-4">
<a
href="#"
class="space-x-1 text-zinc-500 hover:text-zinc-800 flex w-full h-12 items-center justify-center transition-colors mb-2"
>
<i class="i-mingcute-question-line text-lg" />
<span>Need help?</span>
</a>
<RouterLink
to="/"
class="space-x-2 border rounded-lg border-slate-200 text-accent hover:scale-105 transition-transform flex w-full h-12 items-center justify-center bg-white drop-shadow-sm"
>
<span class="i-mingcute-home-1-line" />
<span>View Site</span>
</RouterLink>
</div>
</div>
</template>
@@ -1,22 +0,0 @@
<script setup lang="ts">
// 仪表盘移动端顶栏:汉堡菜单 + Logo
import Logo from '@/components/common/Logo.vue'
const emit = defineEmits<{ toggle: [] }>()
</script>
<template>
<div
class="w-full top-0 h-16 bg-slate-50 z-20 transition-all flex flex-row fixed px-5 md:px-10 items-center lg:hidden"
>
<button class="mr-3" aria-label="Toggle menu" @click="emit('toggle')">
<i class="i-mingcute-menu-line text-2xl text-zinc-500" />
</button>
<RouterLink to="/" class="text-xl font-extrabold flex items-center">
<div class="inline-block size-8 mr-2">
<Logo :size="30" />
</div>
MyBlog
</RouterLink>
</div>
</template>
@@ -1,154 +0,0 @@
<script setup lang="ts">
// 文章/页面管理列表:头部操作 + 状态筛选 + 列表项
import { computed, ref } from 'vue'
import EmptyState from '@/components/common/EmptyState.vue'
import FormattedNumber from '@/components/common/FormattedNumber.vue'
import Tabs from '@/components/ui/Tabs.vue'
import type { Post } from '@/mock/data'
import { allPosts } from '@/mock/data'
import { estimateReadingTime } from '@/lib/utils'
const props = withDefaults(
defineProps<{ title?: string; itemLabel?: string }>(),
{ title: 'Posts', itemLabel: 'Post' },
)
const tabs = [
{ key: 'all', label: 'All Posts' },
{ key: 'published', label: 'Published' },
{ key: 'draft', label: 'Draft' },
{ key: 'scheduled', label: 'Scheduled' },
]
const filter = ref('all')
const list = ref<Post[]>([...allPosts])
const menuOpenFor = ref<string | null>(null)
const filtered = computed(() => {
if (filter.value === 'all') return list.value
return list.value.filter((p) => (p.status ?? 'published') === filter.value)
})
function statusText(status?: Post['status']) {
switch (status) {
case 'draft':
return 'Draft'
case 'scheduled':
return 'Scheduled'
default:
return 'Published'
}
}
function remove(id: string) {
list.value = list.value.filter((p) => p.id !== id)
menuOpenFor.value = null
}
</script>
<template>
<div class="max-w-screen-lg">
<!-- 头部 -->
<header class="mb-4 space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">{{ title }}</h2>
</div>
<div class="space-x-4">
<RouterLink to="/dashboard/editor" class="button is-primary space-x-2 inline-flex">
<i class="i-mingcute-add-line inline-block" />
<span>New {{ itemLabel }}</span>
</RouterLink>
<button class="button is-secondary space-x-2">
<i class="i-mingcute-file-import-line inline-block" />
<span>Import</span>
</button>
</div>
</header>
<!-- 筛选标签 -->
<Tabs :items="tabs" :active-key="filter" @select="filter = $event" />
<!-- 文章列表 -->
<div class="space-y-0">
<RouterLink
v-for="post in filtered"
:key="post.id"
:to="`/dashboard/editor?id=${post.id}`"
class="group relative hover:bg-zinc-100 rounded-lg py-4 px-3 transition-colors -mx-3 flex max-sm:flex-col gap-4"
>
<!-- 封面图 -->
<div class="rounded-lg sm:w-48 overflow-hidden shrink-0">
<img
class="w-full aspect-video object-cover"
:src="post.cover"
:alt="post.title"
loading="lazy"
/>
</div>
<!-- 信息区 -->
<div class="min-w-0 flex-1 flex flex-col justify-between">
<div class="xlog-post-title font-bold text-base text-zinc-700">
<span>{{ post.title }}</span>
</div>
<div class="xlog-post-excerpt text-zinc-500 line-clamp-1 text-sm">
{{ post.excerpt }}
</div>
<div
class="xlog-post-meta text-zinc-400 flex items-center text-[13px] h-[26px] truncate"
>
<span
v-if="post.tags[0]"
class="border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs mr-2"
>
<i class="i-mingcute-tag-line mr-[2px]" />
{{ post.tags[0] }}
</span>
<span class="xlog-post-word-count sm:inline-flex items-center hidden mr-2">
<i class="i-mingcute-time-line mr-[2px]" />
<span style="word-spacing: -.2ch">{{ estimateReadingTime(post.content) }} min</span>
</span>
<span class="xlog-post-views inline-flex items-center">
<i class="i-mingcute-eye-line mr-[2px]" />
<span><FormattedNumber :value="post.views" /></span>
</span>
</div>
<div class="text-zinc-400 text-sm">
<span class="capitalize">{{ statusText(post.status) }}</span>
<span class="mx-2">·</span>
<span>{{ post.date }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="shrink-0 flex gap-2 sm:self-center sm:ml-auto relative">
<button
class="text-gray-400 size-8 rounded inline-flex hover:bg-gray-200 justify-center items-center"
aria-label="More actions"
@click.prevent="menuOpenFor = menuOpenFor === post.id ? null : post.id"
>
<i class="i-mingcute-more-1-line text-2xl" />
</button>
<!-- 操作菜单 -->
<div
v-if="menuOpenFor === post.id"
class="absolute right-0 top-full mt-1 z-10 bg-white border rounded-xl shadow-modal p-1 min-w-[140px]"
@click.stop
>
<button
class="flex w-full px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-100 rounded-lg items-center"
@click="remove(post.id)"
>
<i class="i-mingcute-delete-2-line mr-2 text-[#f91880]" />
Delete
</button>
</div>
</div>
</RouterLink>
<!-- 空状态 -->
<EmptyState v-if="!filtered.length" text="No posts yet" />
</div>
</div>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Tabs from '@/components/ui/Tabs.vue'
const route = useRoute()
const items = [
{ key: 'featured', label: 'Featured', to: '/' },
{ key: 'shorts', label: 'Shorts', to: '/shorts' },
{ key: 'latest', label: 'Latest', to: '/latest' },
{ key: 'hottest', label: 'Hottest', to: '/hottest' },
{ key: 'following', label: 'Following', to: '/following' },
]
const activeKey = computed(
() => items.find((item) => item.to === route.path)?.key ?? 'featured',
)
</script>
<template>
<Tabs :items="items" :active-key="activeKey" class="border-none" />
</template>
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
// 主页文章流:按类型过滤/排序,含 AI 过滤开关(仅演示交互)
import { computed, ref } from 'vue'
import PostCard from '@/components/common/PostCard.vue'
import Skeleton from '@/components/common/Skeleton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { allPosts } from '@/mock/data'
type FeedType = 'featured' | 'shorts' | 'latest' | 'hottest' | 'following'
const props = withDefaults(defineProps<{ type?: FeedType }>(), { type: 'featured' })
const loading = ref(false)
const ai = ref(true)
const posts = computed(() => {
switch (props.type) {
case 'latest':
return [...allPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
)
case 'hottest':
return [...allPosts].sort((a, b) => b.views - a.views)
case 'featured': {
const featured = allPosts.filter((p) => p.featured)
return featured.length ? featured : allPosts
}
default:
return []
}
})
</script>
<template>
<div class="space-y-10">
<!-- AI 过滤开关 -->
<div class="flex items-center text-zinc-500">
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
<span class="text-sm">AI filter</span>
<button
type="button"
aria-label="Toggle AI filter"
class="ml-5 relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="ai ? 'bg-accent' : 'bg-gray-200'"
@click="ai = !ai"
>
<span
class="inline-block size-4 rounded-full bg-white transition"
:class="ai ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<!-- 列表 -->
<Skeleton v-if="loading" />
<div v-else-if="posts.length" class="xlog-posts my-8 min-h-[1177px]">
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3">
<PostCard v-for="post in posts" :key="post.id" :post="post" />
</div>
</div>
<EmptyState v-else text="No posts yet" />
</div>
</template>
@@ -1,59 +0,0 @@
<script setup lang="ts">
// 主页右侧边栏:推广链接 + 搜索 + 推荐创作者 + CSB 领取
import { ref } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import { sites } from '@/mock/data'
import SearchInput from './SearchInput.vue'
const creators = sites
const claimed = ref(false)
</script>
<template>
<aside class="w-80 pl-10 hidden lg:block space-y-10">
<!-- 推广链接 -->
<div class="space-y-5">
<RouterLink to="/about" class="flex items-center text-zinc-500 hover:text-accent">
<i class="i-mingcute-information-line mr-2" />
About
</RouterLink>
<a href="#" class="flex items-center text-zinc-500 hover:text-accent">
<i class="i-mingcute-question-line mr-2" />
Help
</a>
</div>
<!-- 搜索 -->
<SearchInput />
<!-- 推荐创作者 -->
<div class="text-center text-zinc-700 space-y-3">
<p class="font-bold text-lg">Suggested creators for you</p>
<ul class="space-y-3">
<li v-for="creator in creators" :key="creator.handle" class="flex align-middle">
<RouterLink class="inline-flex align-middle w-full" :to="`/site/${creator.handle}`">
<span class="size-10 inline-block">
<Avatar :src="creator.avatar" :name="creator.name" :size="40" />
</span>
<span class="ml-3 min-w-0 flex-1 justify-center inline-flex flex-col">
<span class="truncate w-full inline-block font-medium">{{ creator.name }}</span>
<span class="text-gray-500 text-xs truncate w-full inline-block mt-1">
{{ creator.description }}
</span>
</span>
</RouterLink>
</li>
</ul>
</div>
<!-- CSB 领取 -->
<div class="text-center text-zinc-700 space-y-3">
<p class="font-bold text-lg">Need More CSB?</p>
<button class="button is-primary is-block" @click="claimed = true">
{{ claimed ? 'Claimed ✓' : 'Claim CSB' }}
</button>
</div>
</aside>
</template>
-31
View File
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const tabs = [
{ key: 'home', label: 'Home', to: '/' },
{ key: 'about', label: 'About', to: '/about' },
{ key: 'github', label: 'GitHub Stars', to: 'https://github.com/Crossbell-Box/xLog' },
]
function isActive(key: string, to: string) {
if (to.startsWith('http')) return false
return route.path === to
}
</script>
<template>
<div class="space-x-14 text-zinc-500 flex">
<a
v-for="tab in tabs"
:key="tab.key"
:href="tab.to"
class="hover:text-accent text-lg"
:class="{ 'text-accent': isActive(tab.key, tab.to) }"
:target="tab.to.startsWith('http') ? '_blank' : undefined"
>
{{ tab.label }}
</a>
</div>
</template>
@@ -1,46 +0,0 @@
<script setup lang="ts">
// 底部推广链接(RSS / GitHub / Discord / Twitter)
const links = [
{
icon: 'i-mingcute-rss-2-fill text-2xl',
color: 'text-[#ee832f]',
href: '/rss.xml',
label: 'RSS',
},
{
icon: 'i-mingcute-github-fill text-2xl',
color: 'text-[#181717] dark:text-[#e6edf3]',
href: 'https://github.com/Crossbell-Box/xLog',
label: 'GitHub',
},
{
icon: 'i-mingcute-discord-fill text-2xl',
color: 'text-[#7289da]',
href: 'https://discord.gg/xLog',
label: 'Discord',
},
{
icon: 'i-mingcute-twitter-fill text-2xl',
color: 'text-[#1DA1F2]',
href: 'https://twitter.com/xLog',
label: 'Twitter',
},
]
</script>
<template>
<div class="flex items-center">
<a
v-for="link in links"
:key="link.label"
:href="link.href"
target="_blank"
rel="noreferrer"
class="flex-1 flex items-center justify-center hover:opacity-80"
:class="link.color"
:aria-label="link.label"
>
<i :class="link.icon" />
</a>
</div>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const query = ref('')
function submit() {
if (!query.value.trim()) return
router.push(`/search?q=${encodeURIComponent(query.value.trim())}`)
}
</script>
<template>
<div class="relative xlog-search-input">
<i class="i-mingcute-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
v-model="query"
class="input is-block pl-9"
placeholder="Search"
@keyup.enter="submit"
/>
</div>
</template>
-137
View File
@@ -1,137 +0,0 @@
<script setup lang="ts">
// 文章互动区(点赞/打赏/分享)+ 评论区
import { ref } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import Time from '@/components/common/Time.vue'
import type { Comment, Post } from '@/mock/data'
import { comments } from '@/mock/data'
const props = defineProps<{ post: Post }>()
// 点赞
const liked = ref(false)
const likeCount = ref(props.post.likes)
function toggleLike() {
liked.value = !liked.value
likeCount.value += liked.value ? 1 : -1
}
// 打赏
const tipped = ref(false)
// 分享(复制链接)
const shared = ref(false)
async function share() {
try {
await navigator.clipboard.writeText(window.location.href)
} catch {
/* ignore clipboard errors */
}
shared.value = true
window.setTimeout(() => (shared.value = false), 2000)
}
// 评论
const list = ref<Comment[]>(comments)
const draft = ref('')
function submitComment() {
const content = draft.value.trim()
if (!content) return
list.value.unshift({
id: `c-${list.value.length + 1}`,
author: {
handle: 'you',
name: 'You',
avatar: '',
},
content,
date: new Date().toISOString(),
likes: 0,
})
draft.value = ''
}
</script>
<template>
<div>
<!-- 互动按钮 -->
<div
class="xlog-reactions flex fill-gray-400 text-gray-500 sm:items-center space-x-6 sm:space-x-10 mt-14 mb-12"
>
<button class="button is-like" :class="{ 'text-[#f91880]': liked }" @click="toggleLike">
<i class="i-mingcute-thumb-up-2-fill mr-2" />
<span>{{ likeCount }}</span>
</button>
<button class="button is-tip" @click="tipped = !tipped">
<i class="i-mingcute-pig-money-line mr-2" />
<span>{{ tipped ? 'Tipped ✓' : 'Tip' }}</span>
</button>
<button class="button is-share" @click="share">
<i class="i-mingcute-share-forward-line mr-2" />
<span>{{ shared ? 'Copied!' : 'Share' }}</span>
</button>
</div>
<!-- 评论区 -->
<div class="xlog-comment mb-10" id="comments">
<div class="xlog-comment-count border-b pb-2 mb-6 font-bold">
Comments ({{ list.length }})
</div>
<!-- 评论输入 -->
<div class="xlog-comment-input flex mb-6">
<span class="mr-3">
<Avatar name="You" :size="45" />
</span>
<div class="flex-1">
<textarea
v-model="draft"
class="input is-block min-h-[74px] py-3 resize-y"
placeholder="Write a comment..."
@keydown.meta.enter="submitComment"
@keydown.ctrl.enter="submitComment"
/>
<div class="flex justify-end mt-2">
<button
class="button is-primary is-sm"
:class="{ 'is-loading': false }"
:disabled="!draft.trim()"
@click="submitComment"
>
Send
</button>
</div>
</div>
</div>
<!-- 评论列表 -->
<div class="xlog-comment-list">
<div
v-for="comment in list"
:key="comment.id"
class="xlog-comment-item mt-6 flex space-x-3"
>
<Avatar :src="comment.author.avatar" :name="comment.author.name" :size="40" />
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 text-sm">
<span class="font-bold">{{ comment.author.name }}</span>
<span class="text-zinc-400 text-xs">
<Time :date="comment.date" />
</span>
</div>
<div class="text-zinc-600 mt-1 leading-relaxed break-words">
{{ comment.content }}
</div>
<button
class="text-zinc-400 text-xs mt-1 hover:text-accent inline-flex items-center"
>
<i class="i-mingcute-thumb-up-2-line mr-1" />
{{ comment.likes }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import type { Post } from '@/mock/data'
import Time from '@/components/common/Time.vue'
defineProps<{ post: Post }>()
</script>
<template>
<div class="xlog-post-meta">
<div class="text-zinc-400 mt-5 space-x-5 flex items-center justify-center">
<!-- 发布日期 -->
<Time class="xlog-post-date whitespace-nowrap" :date="post.date" />
<!-- 标签 -->
<span class="xlog-post-tags space-x-1 truncate min-w-0">
<a
v-for="tag in post.tags"
:key="tag"
class="hover:text-accent"
:href="`/tag/${tag}`"
>
#{{ tag }}
</a>
</span>
<!-- 阅读量 -->
<span class="xlog-post-views inline-flex items-center">
<i class="i-mingcute-eye-line mr-[2px]" />
<span>{{ post.views }}</span>
</span>
</div>
</div>
</template>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import type { Post } from '@/mock/data'
defineProps<{ post: Post }>()
</script>
<template>
<h2
class="xlog-post-title mb-8 flex items-center justify-center text-center relative text-4xl font-extrabold"
>
<span>{{ post.title }}</span>
</h2>
</template>
@@ -1,43 +0,0 @@
<script setup lang="ts">
// 站点页 Footer:版权 + 语言/暗色切换
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
import Logo from '@/components/common/Logo.vue'
import { sites } from '@/mock/data'
const route = useRoute()
const site = computed(
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
)
</script>
<template>
<footer class="text-zinc-500 border-t">
<div class="max-w-screen-lg mx-auto px-5 py-10">
<div
class="text-xs sm:flex justify-between sm:space-x-5 sm:space-y-0 space-y-5 sm:items-center"
>
<div class="font-medium text-base">
&copy;
<RouterLink :to="`/site/${site.handle}`" class="hover:text-accent">
{{ site.name }}
</RouterLink>
· powered by
<RouterLink to="/" class="inline-flex items-center align-middle hover:text-accent">
<span class="inline-block size-5 mr-1">
<Logo :size="20" />
</span>
xLog
</RouterLink>
</div>
<div class="flex gap-x-2 items-center justify-center">
<LanguageSwitch />
<DarkModeSwitch />
</div>
</div>
</div>
</footer>
</template>
@@ -1,73 +0,0 @@
<script setup lang="ts">
// 站点信息头:Banner + 头像 + 站点名/简介 + 关注按钮 + 导航
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { sites } from '@/mock/data'
import SiteTabs from './SiteTabs.vue'
const route = useRoute()
const site = computed(
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
)
const following = ref(false)
</script>
<template>
<header class="xlog-header border-b border-zinc-100 relative">
<!-- 可选 Banner 图 -->
<div v-if="site.banner" class="xlog-banner absolute inset-0 overflow-hidden">
<img class="object-cover w-full h-full" :src="site.banner" alt="banner" />
</div>
<div class="px-5 max-w-screen-lg mx-auto h-full relative flex items-center flex-col z-10">
<div class="flex py-12 w-full">
<div class="xlog-site-info flex space-x-6 sm:space-x-8 w-full">
<!-- 头像 -->
<img
class="xlog-site-icon max-w-[100px] max-h-[100px] sm:max-w-none sm:max-h-none rounded-full bg-zinc-100"
:src="site.avatar"
width="150"
height="150"
:alt="`${site.name} avatar`"
/>
<!-- 站点信息 -->
<div class="flex-1 min-w-0 relative space-y-2 sm:space-y-3 min-h-[108px]">
<div class="flex items-center justify-between">
<h1
class="xlog-site-name text-3xl sm:text-4xl font-bold text-zinc-900 leading-snug break-words min-w-0"
>
{{ site.name }}
</h1>
<div class="ml-0 sm:ml-8 space-x-3 sm:space-x-4 flex items-center">
<button
class="button is-primary is-sm"
@click="following = !following"
>
{{ following ? 'Following' : 'Follow' }}
</button>
</div>
</div>
<!-- 简介 -->
<div
class="xlog-site-description text-gray-500 leading-snug text-sm sm:text-base line-clamp-4 whitespace-pre-wrap"
>
{{ site.description }}
</div>
<!-- 关注数 -->
<span class="text-sm text-zinc-400">{{ site.followers }} followers</span>
</div>
</div>
</div>
<!-- 导航栏 -->
<div class="text-gray-500 flex items-center justify-between w-full mt-auto">
<SiteTabs />
</div>
</div>
</header>
</template>
-25
View File
@@ -1,25 +0,0 @@
<script setup lang="ts">
// 站点头导航:Home / Archives
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Tabs from '@/components/ui/Tabs.vue'
const route = useRoute()
const items = computed(() => {
const site = route.params.site as string
return [
{ key: 'home', label: 'Home', to: `/site/${site}` },
{ key: 'archives', label: 'Archives', to: `/site/${site}/archives` },
]
})
const activeKey = computed(() =>
route.path.includes('/archives') ? 'archives' : 'home',
)
</script>
<template>
<Tabs :items="items" :active-key="activeKey" class="border-none mb-0" />
</template>
-56
View File
@@ -1,56 +0,0 @@
<script setup lang="ts">
// 通用 Tabs:underline(下划线)与 rounded(胶囊)两种类型
// 有 `to` 的项渲染为 RouterLink,无 `to` 的项渲染为按钮并触发 select
export interface TabItem {
key: string
label: string
to?: string
icon?: string
}
const props = withDefaults(
defineProps<{ items: TabItem[]; activeKey: string; type?: 'underline' | 'rounded' }>(),
{ type: 'underline' },
)
const emit = defineEmits<{ select: [key: string] }>()
function cls(item: TabItem) {
const base =
'inline-flex items-center whitespace-nowrap cursor-pointer transition-colors relative'
if (props.type === 'rounded') {
return [
base,
'rounded-full h-8 px-3',
props.activeKey === item.key
? 'bg-zinc-950 text-white'
: 'bg-zinc-100 text-zinc-800 hover:bg-zinc-200',
].join(' ')
}
return [
base,
'h-10',
props.activeKey === item.key
? 'text-accent font-medium border-b-2 border-accent'
: 'text-gray-600 hover:text-accent',
].join(' ')
}
</script>
<template>
<div
class="flex mb-8 overflow-x-auto scrollbar-hide"
:class="type === 'rounded' ? 'space-x-3 text-sm' : 'space-x-5 border-b'"
>
<template v-for="item in items" :key="item.key">
<RouterLink v-if="item.to" :to="item.to" :class="cls(item)">
<i v-if="item.icon" :class="item.icon" class="mr-2" />
{{ item.label }}
</RouterLink>
<button v-else type="button" :class="cls(item)" @click="emit('select', item.key)">
<i v-if="item.icon" :class="item.icon" class="mr-2" />
{{ item.label }}
</button>
</template>
</div>
</template>