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

This commit is contained in:
Sakurasan committed 2026-09-20 20:56:03 +08:00
1 parent edee708802
commit 4d8f2de3a4
96 files changed
+6005 -2602

No files matched your search

-24
View File
@@ -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>
+85
View File
@@ -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>
+175
View File
@@ -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>
-78
View File
@@ -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>
-45
View File
@@ -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>
-30
View File
@@ -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>
+210
View File
@@ -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>
-15
View File
@@ -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>
+48
View File
@@ -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>
-8
View File
@@ -1,8 +0,0 @@
<script setup lang="ts">
// 页面管理页
import PagesManager from '@/components/dashboard/PagesManager.vue'
</script>
<template>
<PagesManager title="Pages" item-label="Page" />
</template>
-46
View File
@@ -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>
+196
View File
@@ -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>
-8
View File
@@ -1,8 +0,0 @@
<script setup lang="ts">
// 文章管理页
import PagesManager from '@/components/dashboard/PagesManager.vue'
</script>
<template>
<PagesManager title="Posts" item-label="Post" />
</template>
-37
View File
@@ -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>
-77
View File
@@ -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>
-48
View File
@@ -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>
-30
View File
@@ -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>
+132
View File
@@ -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>
+117
View File
@@ -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>