MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
1 parent
edee708802
commit
4d8f2de3a4
96 files changed
+6005
-2602
No files matched your search
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 关于页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import PromotionLinks from '@/components/home/PromotionLinks.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-lg mx-auto py-8">
|
||||
<div class="text-center py-10 space-y-6">
|
||||
<div class="inline-block size-16 mx-auto">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-4xl font-extrabold">About xLog</h1>
|
||||
<p class="text-zinc-500 max-w-xl mx-auto leading-loose">
|
||||
xLog is a decentralized blog platform built on Crossbell. It gives every
|
||||
creator a permanent home on the open web — own your content, connect with
|
||||
readers, and tip creators across the network.
|
||||
</p>
|
||||
<div class="max-w-sm mx-auto">
|
||||
<PromotionLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
applyDocTitle('关于')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">关于</h1>
|
||||
</header>
|
||||
|
||||
<div class="wrap prose">
|
||||
<p>{{ site.site_desc || '长文与短文,同一种节奏。' }}</p>
|
||||
<h2>这里写什么</h2>
|
||||
<p>
|
||||
<strong>长文</strong>是正常的博客文章:有标题、有结构,适合把一件事讲完整。
|
||||
</p>
|
||||
<p>
|
||||
<strong>短文</strong>像一条推文:一两段话,没有标题,用来记一个念头、一段心情。
|
||||
它们在同一条时间线里流动,只是密度不同。
|
||||
</p>
|
||||
<h2>订阅</h2>
|
||||
<p>
|
||||
站点提供 RSS:<a href="/rss.xml">/rss.xml</a>,短文在标题里带「短」标记。
|
||||
</p>
|
||||
<p class="foot">{{ site.footer_note }}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 24px 26px 40px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 18px 4px 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
import { formatDateShort } from '../utils'
|
||||
|
||||
const years = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.archive()
|
||||
years.value = data.years || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const total = computed(() =>
|
||||
years.value.reduce((n, y) => n + (y.count || 0), 0)
|
||||
)
|
||||
|
||||
applyDocTitle('归档')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">归档</h1>
|
||||
<p class="sub">共 {{ total }} 篇,按时间倒序。</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!years.length" class="empty">还没有已发布的文章。</div>
|
||||
|
||||
<section v-for="y in years" :key="y.year" class="year">
|
||||
<h2 class="year-title">{{ y.year }}<span class="count">{{ y.count }} 篇</span></h2>
|
||||
|
||||
<div v-for="m in y.months" :key="y.year + m.month" class="month">
|
||||
<p class="month-label">{{ Number(m.month) }} 月</p>
|
||||
<ul class="list">
|
||||
<li v-for="p in m.posts" :key="p.id">
|
||||
<RouterLink :to="`/post/${p.slug}`" class="row">
|
||||
<span class="date">{{ formatDateShort(p.published_at).slice(5) }}</span>
|
||||
<span class="title">
|
||||
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
|
||||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.year {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
|
||||
.year-title {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.month {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘主页:统计卡片 + 最近文章
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: String(allPosts.length),
|
||||
icon: 'i-mingcute-news-line',
|
||||
},
|
||||
{
|
||||
label: 'Total Views',
|
||||
value: allPosts.reduce((sum, p) => sum + p.views, 0).toLocaleString(),
|
||||
icon: 'i-mingcute-eye-line',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: '1,284',
|
||||
icon: 'i-mingcute-user-follow-line',
|
||||
},
|
||||
{
|
||||
label: 'CSB Balance',
|
||||
value: '1,024',
|
||||
icon: 'i-mingcute-pig-money-line',
|
||||
},
|
||||
])
|
||||
|
||||
const recent = computed(() =>
|
||||
[...allPosts]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 5),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="border rounded-xl p-4 flex items-center space-x-3"
|
||||
>
|
||||
<span class="size-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center shrink-0">
|
||||
<i :class="stat.icon" class="text-xl" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-bold">{{ stat.value }}</div>
|
||||
<div class="text-zinc-500 text-sm truncate">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近文章 -->
|
||||
<div class="mt-8">
|
||||
<h3 class="font-bold text-lg mb-3">Recent Posts</h3>
|
||||
<div class="border rounded-xl divide-y">
|
||||
<RouterLink
|
||||
v-for="post in recent"
|
||||
:key="post.id"
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center px-4 py-3 hover:bg-zinc-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
class="w-12 h-8 rounded object-cover mr-3 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 truncate font-medium">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">{{ post.views }} views</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 简易 Markdown 编辑器(占位实现,仅用于文章管理页跳转)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() => {
|
||||
const id = route.query.id as string | undefined
|
||||
return allPosts.find((p) => p.id === id) ?? allPosts[0]
|
||||
})
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
watch(
|
||||
post,
|
||||
(p) => {
|
||||
if (p) {
|
||||
title.value = p.title
|
||||
content.value = p.content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Editor">
|
||||
<div class="space-y-4 max-w-screen-lg">
|
||||
<input v-model="title" class="input is-block text-lg font-bold" placeholder="Title" />
|
||||
<textarea
|
||||
v-model="content"
|
||||
class="input is-block min-h-[400px] py-3 font-mono text-sm resize-y"
|
||||
placeholder="Write in Markdown..."
|
||||
/>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button class="button is-secondary">Save Draft</button>
|
||||
<button class="button is-primary">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 主页:活动标签 + 文章 Grid + 右侧边栏
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import HomeActivitiesTabs from '@/components/home/HomeActivitiesTabs.vue'
|
||||
import HomeFeed from '@/components/home/HomeFeed.vue'
|
||||
import HomeSidebar from '@/components/home/HomeSidebar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const feedType = computed(() => {
|
||||
const path = route.path.replace(/^\//, '')
|
||||
if (['shorts', 'latest', 'hottest', 'following'].includes(path)) {
|
||||
return path as 'shorts' | 'latest' | 'hottest' | 'following'
|
||||
}
|
||||
return 'featured'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 主栏 -->
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<HomeActivitiesTabs />
|
||||
<HomeFeed :type="feedType" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏(桌面端显示) -->
|
||||
<HomeSidebar />
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import PostCard from '../components/PostCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const kind = computed(() => route.query.kind || '')
|
||||
const tag = computed(() => route.query.tag || '')
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
const size = computed(() => site.posts_per_page || 10)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '长文', value: 'long' },
|
||||
{ label: '短文', value: 'short' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await publicApi.posts({
|
||||
kind: kind.value,
|
||||
tag: tag.value,
|
||||
page: page.value,
|
||||
size: size.value
|
||||
})
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([kind, tag, page], load)
|
||||
|
||||
function setKind(value) {
|
||||
router.push({ path: '/', query: { ...(value ? { kind: value } : {}), ...(tag.value ? { tag: tag.value } : {}) } })
|
||||
}
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
|
||||
|
||||
const heading = computed(() => {
|
||||
if (tag.value) return `#${tag.value}`
|
||||
if (kind.value === 'long') return '长文'
|
||||
if (kind.value === 'short') return '短文'
|
||||
return '时间线'
|
||||
})
|
||||
|
||||
applyDocTitle('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="sticky-head">
|
||||
<h1 class="page-title">{{ heading }}</h1>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
:key="t.value"
|
||||
class="tab"
|
||||
:class="{ active: kind === t.value }"
|
||||
@click="setKind(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
<a v-if="tag" class="tab clear" href="#" @click.prevent="router.push({ path: '/' })">
|
||||
清除标签 ×
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}(后端是否已启动?)</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
这里还空着。<RouterLink to="/admin">去后台写第一篇 →</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<PostCard v-for="p in items" :key="p.id" :post="p" />
|
||||
|
||||
<nav v-if="pageCount > 1" class="pager">
|
||||
<RouterLink
|
||||
v-if="page > 1"
|
||||
class="btn"
|
||||
:to="{ path: '/', query: { ...route.query, page: page - 1 } }"
|
||||
>
|
||||
← 上一页
|
||||
</RouterLink>
|
||||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<RouterLink
|
||||
v-if="page < pageCount"
|
||||
class="btn"
|
||||
:to="{ path: '/', query: { ...route.query, page: page + 1 } }"
|
||||
>
|
||||
下一页 →
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.sticky-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 8px 14px 10px;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--ink);
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab.clear {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 16px 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 404 页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-white flex flex-col items-center justify-center py-20 space-y-6">
|
||||
<div class="inline-block size-16">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-6xl font-extrabold">404</h1>
|
||||
<p class="text-zinc-500">This page could not be found.</p>
|
||||
<RouterLink to="/" class="button is-primary">Back to Home</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
<main class="main">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">404</p>
|
||||
<h1 class="title">这一页不存在</h1>
|
||||
<p class="sub">链接可能已经失效,或者文章被撤回了。</p>
|
||||
<RouterLink to="/" class="pill pill-ghost">回到时间线</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 60px 26px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 页面管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Pages" item-label="Page" />
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章详情页:标题 + 元信息 + AI 摘要 + Markdown 正文 + 互动区 + 评论
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import PostFooter from '@/components/site/PostFooter.vue'
|
||||
import PostMeta from '@/components/site/PostMeta.vue'
|
||||
import PostTitle from '@/components/site/PostTitle.vue'
|
||||
import { getPost } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() =>
|
||||
getPost(route.params.site as string, route.params.slug as string),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<article v-if="post">
|
||||
<!-- 文章标题 -->
|
||||
<PostTitle :post="post" />
|
||||
|
||||
<!-- 文章元信息 -->
|
||||
<PostMeta :post="post" />
|
||||
|
||||
<!-- AI 摘要 -->
|
||||
<div class="xlog-post-summary border rounded-xl mt-5 p-4 space-y-2">
|
||||
<div class="font-bold text-zinc-700 flex items-center">
|
||||
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
|
||||
AI-generated summary
|
||||
</div>
|
||||
<div class="text-zinc-500 leading-loose text-sm">{{ post.excerpt }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 正文 -->
|
||||
<MarkdownContent :content="post.content" class="mt-10" />
|
||||
|
||||
<!-- 互动区 + 评论区 -->
|
||||
<PostFooter :post="post" />
|
||||
</article>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Post not found" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
import { formatDate, minutesLabel } from '../utils'
|
||||
|
||||
const route = useRoute()
|
||||
const post = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
post.value = null
|
||||
try {
|
||||
post.value = await publicApi.post(route.params.slug)
|
||||
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => route.params.slug, load)
|
||||
|
||||
const isShort = computed(() => post.value && post.value.kind === 'short')
|
||||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
|
||||
<div v-else-if="error" class="empty">
|
||||
{{ error }}
|
||||
<p><RouterLink to="/">← 回到时间线</RouterLink></p>
|
||||
</div>
|
||||
|
||||
<article v-else class="wrap" :class="{ short: isShort }">
|
||||
<header class="head">
|
||||
<div class="byline">
|
||||
<div class="avatar">{{ initial }}</div>
|
||||
<div class="who">
|
||||
<span class="author">{{ site.author_name || 'ONE' }}</span>
|
||||
<span class="sub">
|
||||
<time :datetime="post.published_at">{{ formatDate(post.published_at) }}</time>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ minutesLabel(post) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短文不渲染大标题 -->
|
||||
<h1 v-if="!isShort" class="title">{{ post.title }}</h1>
|
||||
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
|
||||
</header>
|
||||
|
||||
<div class="prose" :class="{ 'prose-short': isShort }" v-html="post.content_html"></div>
|
||||
|
||||
<footer class="foot">
|
||||
<div 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>
|
||||
</div>
|
||||
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 26px 26px 40px;
|
||||
}
|
||||
|
||||
.head {
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
margin: 0 5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 20px 0 0;
|
||||
font-size: 32px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 10px 0 0;
|
||||
font-family: var(--serif);
|
||||
font-size: 16.5px;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: 32px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 18px 4px 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 27px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Posts" item-label="Post" />
|
||||
</template>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 搜索页:基于 URL query 过滤 mock 文章
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const keyword = computed(() => String(route.query.q ?? '').trim().toLowerCase())
|
||||
|
||||
const results = computed(() => {
|
||||
if (!keyword.value) return []
|
||||
return allPosts.filter((post) =>
|
||||
[post.title, post.excerpt, ...post.tags, post.author.name]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword.value),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<div class="flex mb-8 border-b">
|
||||
<h2 class="inline-flex items-center h-10 whitespace-nowrap cursor-pointer text-accent font-medium border-b-2 border-accent">
|
||||
Results for "{{ keyword }}"
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="results.length" class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
|
||||
<PostCard v-for="post in results" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else :text="keyword ? 'No results found' : 'Type something to search'" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 设置页:General / Social / Navigation / Domains / Custom CSS
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tab = computed(() => (route.params.tab as string) || 'general')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general', label: 'General', to: '/dashboard/settings/general' },
|
||||
{ key: 'social', label: 'Social Platforms', to: '/dashboard/settings/social' },
|
||||
{ key: 'navigation', label: 'Navigation', to: '/dashboard/settings/navigation' },
|
||||
{ key: 'domains', label: 'Domains', to: '/dashboard/settings/domains' },
|
||||
{ key: 'css', label: 'Custom CSS', to: '/dashboard/settings/css' },
|
||||
]
|
||||
|
||||
const form = ref({ name: 'MyBlog', description: 'A blog about code and life.' })
|
||||
const saved = ref(false)
|
||||
|
||||
watch(form, () => (saved.value = false), { deep: true })
|
||||
|
||||
function save() {
|
||||
saved.value = true
|
||||
window.setTimeout(() => (saved.value = false), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col">
|
||||
<header class="mb-8">
|
||||
<h2 class="text-2xl font-bold">Settings</h2>
|
||||
</header>
|
||||
<div>
|
||||
<!-- 设置子标签 -->
|
||||
<Tabs :items="tabs" :active-key="tab" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<div class="max-w-screen-md">
|
||||
<!-- General -->
|
||||
<div v-if="tab === 'general'" class="space-y-6">
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Site Name</label>
|
||||
<input v-model="form.name" class="input is-block" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="input is-block min-h-[74px] py-3 resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Avatar</label>
|
||||
<button class="button is-secondary">
|
||||
<i class="i-mingcute-camera-line mr-2" />
|
||||
Upload Avatar
|
||||
</button>
|
||||
</div>
|
||||
<button class="button is-primary" @click="save">
|
||||
{{ saved ? 'Saved ✓' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 其他设置(占位) -->
|
||||
<EmptyState
|
||||
v-else
|
||||
:icon="'i-mingcute-settings-3-line'"
|
||||
:text="`${tab} settings are coming soon`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点归档页:按月份分组的文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Time from '@/components/common/Time.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const grouped = computed(() => {
|
||||
const posts = allPosts
|
||||
.filter((p) => p.siteId === route.params.site)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
const map = new Map<string, typeof posts>()
|
||||
for (const post of posts) {
|
||||
const key = post.date.slice(0, 7) // YYYY-MM
|
||||
map.set(key, [...(map.get(key) ?? []), post])
|
||||
}
|
||||
return [...map.entries()]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<div v-if="grouped.length">
|
||||
<div v-for="[month, posts] in grouped" :key="month" class="mb-8">
|
||||
<h3 class="text-xl font-bold text-zinc-900 mb-3">{{ month }}</h3>
|
||||
<ul class="border-t divide-y">
|
||||
<li v-for="post in posts" :key="post.id">
|
||||
<RouterLink
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center justify-between py-3 hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="truncate">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">
|
||||
<Time :date="post.date" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点主页:该创作者的公开文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts, sites } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? null,
|
||||
)
|
||||
|
||||
const posts = computed(() => allPosts.filter((p) => p.siteId === route.params.site))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="site">
|
||||
<div
|
||||
v-if="posts.length"
|
||||
class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-2 my-8"
|
||||
>
|
||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Site not found" />
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import PostCard from '../components/PostCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
|
||||
const route = useRoute()
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const size = 20
|
||||
|
||||
const name = computed(() => route.params.slug)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await publicApi.posts({ tag: name.value, page: 1, size })
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
|
||||
watch(page, load)
|
||||
|
||||
applyDocTitle('标签')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">#{{ name }}</h1>
|
||||
<p class="sub">{{ total }} 篇 · <RouterLink to="/tags">全部标签</RouterLink></p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">这个标签下还没有文章。</div>
|
||||
|
||||
<template v-else>
|
||||
<PostCard v-for="p in items" :key="p.id" :post="p" />
|
||||
<nav v-if="pageCount > 1" class="pager">
|
||||
<RouterLink
|
||||
v-if="page > 1"
|
||||
class="btn"
|
||||
:to="{ path: `/tag/${name}`, query: { page: page - 1 } }"
|
||||
>
|
||||
← 上一页
|
||||
</RouterLink>
|
||||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<RouterLink
|
||||
v-if="page < pageCount"
|
||||
class="btn"
|
||||
:to="{ path: `/tag/${name}`, query: { page: page + 1 } }"
|
||||
>
|
||||
下一页 →
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 16px 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.tags()
|
||||
tags.value = data.tags || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const max = () => Math.max(1, ...tags.value.map((t) => t.count))
|
||||
|
||||
applyDocTitle('标签')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">标签</h1>
|
||||
<p class="sub">共 {{ tags.length }} 个标签。</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!tags.length" class="empty">还没有标签。</div>
|
||||
|
||||
<div v-else class="cloud">
|
||||
<RouterLink
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:to="`/tag/${t.slug}`"
|
||||
class="tag"
|
||||
:style="{ fontSize: 14 + Math.round((t.count / max()) * 8) + 'px' }"
|
||||
>
|
||||
{{ t.name }}<span class="count">{{ t.count }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: var(--ink-soft);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user