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
+4 -3
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 根组件:仅承载路由出口
<script setup>
import TopBar from './components/TopBar.vue'
</script>
<template>
<router-view />
<TopBar />
<RouterView />
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { checkAuth } from './auth'
import { adminApi, session } from '../api'
import { site } from '../site'
const router = useRouter()
const ready = ref(false)
onMounted(async () => {
if (!(await checkAuth())) {
router.replace('/admin/login')
return
}
ready.value = true
})
// 会话过期时(请求返回 401)统一跳回登录页
window.addEventListener('one:unauthorized', () => {
session.clear()
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
})
async function logout() {
try {
await adminApi.logout()
} catch (e) {
/* 忽略 */
}
session.clear()
router.replace('/admin/login')
}
</script>
<template>
<div v-if="ready" class="admin">
<header class="bar">
<div class="brand">
<RouterLink to="/" class="site">{{ site.site_title || 'ONE' }}</RouterLink>
<span class="sep">/</span>
<span class="where">后台</span>
</div>
<nav class="nav">
<RouterLink to="/admin" class="link">文章</RouterLink>
<RouterLink to="/admin/new" class="link">写新的</RouterLink>
<RouterLink to="/admin/tags" class="link">标签</RouterLink>
<RouterLink to="/admin/settings" class="link">设置</RouterLink>
<RouterLink to="/" class="link">看前台</RouterLink>
<button class="link as-btn" @click="logout">退出</button>
</nav>
</header>
<main class="content">
<RouterView />
</main>
</div>
<div v-else class="checking">检查登录状态…</div>
</template>
<style scoped>
.admin {
min-height: 100vh;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: var(--card);
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: baseline;
gap: 8px;
}
.site {
font-family: var(--serif);
font-size: 17px;
}
.sep {
color: var(--faint);
}
.where {
font-size: 13px;
color: var(--muted);
}
.nav {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.link {
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
color: var(--ink-soft);
border: 0;
background: none;
cursor: pointer;
}
.link:hover {
background: var(--paper-sunken);
color: var(--ink);
}
.link.router-link-exact-active {
color: var(--accent);
}
.as-btn {
color: var(--muted);
}
.content {
max-width: 980px;
margin: 0 auto;
padding: 26px 22px 80px;
}
.checking {
padding: 80px;
text-align: center;
color: var(--muted);
}
</style>
+605
View File
@@ -0,0 +1,605 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { md } from '../utils'
const route = useRoute()
const router = useRouter()
const id = computed(() => (route.params.id ? Number(route.params.id) : 0))
const isEdit = computed(() => id.value > 0)
const loading = ref(true)
const saving = ref(false)
const savedAt = ref('')
const error = ref('')
const tab = ref('write') // write | preview
const tagInput = ref('')
const dirty = ref(false)
const DRAFT_KEY = 'one.draft.new'
const form = reactive({
kind: 'long',
title: '',
slug: '',
summary: '',
content_md: '',
tags: [],
status: 'draft',
published_at: '',
reading_minutes: null,
override_minutes: false
})
// ---------- 阅读时长 ----------
function estimateMinutes(text) {
if (!text || !text.trim()) return 1
const cjk = (text.match(/[㐀-鿿 -〿＀-￯]/g) || []).length
const latin = (text.match(/[A-Za-z0-9']+/g) || []).length
return Math.max(1, Math.floor(cjk / 400) + Math.floor(latin / 220))
}
const autoMinutes = computed(() => estimateMinutes(form.content_md))
const finalMinutes = computed(() =>
form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : autoMinutes.value
)
// ---------- 时间 ----------
function isoToLocal(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(
d.getMinutes()
)}`
}
function localToIso(local) {
if (!local) return ''
const d = new Date(local)
if (Number.isNaN(d.getTime())) return ''
return d.toISOString()
}
const publishedLocal = ref('')
// ---------- 载入 ----------
onMounted(async () => {
if (isEdit.value) {
try {
const p = await adminApi.post(id.value)
form.kind = p.kind || 'long'
form.title = p.title || ''
form.slug = p.slug || ''
form.summary = p.summary || ''
form.content_md = p.content_md || ''
form.tags = p.tags ? [...p.tags] : []
form.status = p.status || 'draft'
publishedLocal.value = isoToLocal(p.published_at)
if (p.reading_minutes && p.reading_minutes !== estimateMinutes(p.content_md || '')) {
form.override_minutes = true
form.reading_minutes = p.reading_minutes
}
} catch (e) {
error.value = e.message || '载入失败'
}
} else {
const raw = localStorage.getItem(DRAFT_KEY)
if (raw) {
try {
Object.assign(form, JSON.parse(raw))
savedAt.value = '本地草稿已恢复'
} catch (e) {
localStorage.removeItem(DRAFT_KEY)
}
}
publishedLocal.value = isoToLocal(new Date().toISOString())
}
loading.value = false
// 载入完成后的第一次变更才触发自动保存
setTimeout(() => {
dirty.value = false
watchForm()
}, 0)
})
// ---------- 自动保存 ----------
let timer
let stopWatch
function watchForm() {
stopWatch = watch(
form,
() => {
dirty.value = true
clearTimeout(timer)
timer = setTimeout(autosave, 1500)
},
{ deep: true }
)
watch(publishedLocal, () => {
dirty.value = true
clearTimeout(timer)
timer = setTimeout(autosave, 1500)
})
}
async function autosave() {
if (loading.value) return
const payload = buildPayload()
if (!payload.content_md.trim() && !payload.title.trim() && !isEdit.value) return
saving.value = true
error.value = ''
try {
if (isEdit.value) {
const saved = await adminApi.updatePost(id.value, payload)
applySaved(saved)
} else {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...form, tags: [...form.tags] }))
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = '自动保存失败:' + (e.message || '')
} finally {
saving.value = false
}
}
function applySaved(saved) {
form.slug = saved.slug
form.status = saved.status
publishedLocal.value = isoToLocal(saved.published_at)
}
function buildPayload() {
return {
kind: form.kind,
title: form.title,
slug: form.slug,
summary: form.summary,
content_md: form.content_md,
tags: [...form.tags],
status: form.status,
published_at: localToIso(publishedLocal.value) || new Date().toISOString(),
reading_minutes: form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : null
}
}
async function save(status) {
if (status) form.status = status
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
error.value = '先写点什么再保存'
return
}
saving.value = true
error.value = ''
clearTimeout(timer)
try {
const payload = buildPayload()
if (isEdit.value) {
applySaved(await adminApi.updatePost(id.value, payload))
} else {
const created = await adminApi.createPost(payload)
localStorage.removeItem(DRAFT_KEY)
dirty.value = false
router.replace(`/admin/${created.id}`)
return
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
async function publishNow() {
await save('published')
}
async function unpublish() {
await save('draft')
}
// ---------- 标签 ----------
function addTag() {
const v = tagInput.value.trim().replace(/[,,]$/, '')
if (!v) return
if (!form.tags.includes(v)) form.tags.push(v)
tagInput.value = ''
}
function onTagInput(e) {
if (e.key === 'Enter' || e.key === ',' || e.key === ',') {
e.preventDefault()
addTag()
}
}
function removeTag(t) {
form.tags = form.tags.filter((x) => x !== t)
}
// ---------- 预览 ----------
const preview = computed(() => md.render(form.content_md || ''))
function onKeydown(e) {
// Tab 缩进,而不是跳出输入框
if (e.key === 'Tab') {
e.preventDefault()
const el = e.target
const start = el.selectionStart
const end = el.selectionEnd
form.content_md =
form.content_md.slice(0, start) + ' ' + form.content_md.slice(end)
setTimeout(() => el.setSelectionRange(start + 2, start + 2))
}
}
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else class="editor">
<header class="head">
<div class="left">
<h1 class="title">{{ isEdit ? '编辑文章' : '写新的' }}</h1>
<span class="state">
<span v-if="saving">保存中…</span>
<span v-else-if="dirty">有未保存的改动</span>
<span v-else-if="savedAt">已保存 {{ savedAt }}</span>
<span v-else>还没改过</span>
</span>
</div>
<div class="right">
<button class="btn" @click="form.status === 'published' ? unpublish() : save()">
存草稿
</button>
<button v-if="form.status !== 'published'" class="btn btn-primary" @click="publishNow">
发布
</button>
<button v-else class="btn" @click="unpublish">转为草稿</button>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<div class="grid">
<!-- 左:正文 -->
<div class="main">
<div class="kind-switch">
<button class="kind" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">
长文
</button>
<button class="kind" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">
短文
</button>
<span class="hint">
{{ form.kind === 'long' ? '有标题、有结构,适合讲完整一件事' : '一两段话,时间线里不显示标题' }}
</span>
</div>
<input
v-model="form.title"
class="input title-input"
:placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'"
/>
<div class="tabs">
<button class="tab" :class="{ on: tab === 'write' }" @click="tab = 'write'">编写</button>
<button class="tab" :class="{ on: tab === 'preview' }" @click="tab = 'preview'">预览</button>
<span class="counter">{{ form.content_md.length }} 字</span>
</div>
<textarea
v-show="tab === 'write'"
v-model="form.content_md"
class="textarea md-input"
rows="20"
:placeholder="form.kind === 'short' ? '写点什么…' : '# 标题\n\n正文,支持 Markdown。'"
@keydown="onKeydown"
></textarea>
<div v-show="tab === 'preview'" class="prose preview" v-html="preview"></div>
</div>
<!-- 右:元信息 -->
<aside class="side">
<div class="field">
<label>类型</label>
<p class="value">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
</div>
<div class="field">
<label for="slug">Slug(链接)</label>
<input id="slug" v-model="form.slug" class="input" placeholder="留空自动生成" />
<p v-if="form.slug" class="sub">/post/{{ form.slug }}</p>
</div>
<div class="field">
<label for="summary">摘要</label>
<textarea
id="summary"
v-model="form.summary"
class="textarea"
rows="3"
placeholder="长文显示在时间线里的一段话"
></textarea>
</div>
<div class="field">
<label>标签</label>
<div v-if="form.tags.length" class="taglist">
<span v-for="t in form.tags" :key="t" class="tag-chip">
{{ t }}
<button class="x" @click="removeTag(t)">×</button>
</span>
</div>
<input
v-model="tagInput"
class="input"
placeholder="输入后回车添加"
@keydown="onTagInput"
@blur="addTag"
/>
</div>
<div class="field">
<label for="pub">发布时间</label>
<input id="pub" v-model="publishedLocal" type="datetime-local" class="input" />
</div>
<div class="field">
<label for="mins">阅读时长(分钟)</label>
<div class="mins-row">
<input
id="mins"
v-model="form.reading_minutes"
type="number"
min="1"
class="input"
:disabled="!form.override_minutes"
:placeholder="String(autoMinutes)"
/>
<label class="check">
<input v-model="form.override_minutes" type="checkbox" />
手动指定
</label>
</div>
<p class="sub">自动估算:{{ finalMinutes }} 分钟</p>
</div>
<div class="field">
<label>状态</label>
<p class="value">
<span class="dot" :class="form.status"></span>
{{ form.status === 'published' ? '已发布' : '草稿' }}
</p>
</div>
<button class="btn wide" @click="save()">立即保存</button>
<RouterLink to="/admin" class="back">← 返回列表</RouterLink>
</aside>
</div>
</section>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.left {
display: flex;
align-items: baseline;
gap: 12px;
}
.title {
font-size: 22px;
}
.state {
font-size: 12.5px;
color: var(--muted);
}
.right {
display: flex;
gap: 8px;
}
.error {
color: #9a5b45;
font-size: 13px;
margin-bottom: 12px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 22px;
align-items: start;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.kind-switch {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.kind {
padding: 5px 14px;
border: 1px solid var(--line);
background: var(--card);
border-radius: 999px;
cursor: pointer;
font-size: 13px;
}
.kind.on {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.title-input {
font-family: var(--serif);
font-size: 19px;
padding: 10px 12px;
margin-bottom: 14px;
}
.tabs {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--line);
margin-bottom: -1px;
}
.tab {
padding: 7px 14px;
border: 1px solid transparent;
border-bottom: 0;
background: none;
cursor: pointer;
font-size: 13.5px;
color: var(--muted);
border-radius: 3px 3px 0 0;
}
.tab.on {
color: var(--accent);
border-color: var(--line);
background: var(--card);
margin-bottom: -1px;
}
.counter {
margin-left: auto;
font-size: 12px;
color: var(--faint);
padding-bottom: 6px;
}
.md-input {
min-height: 420px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.8;
border-radius: 0 3px 3px 3px;
}
.preview {
min-height: 420px;
padding: 18px 20px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 0 3px 3px 3px;
}
.side {
border: 1px solid var(--line);
background: var(--card);
border-radius: 4px;
padding: 18px;
}
.value {
margin: 0;
font-size: 14px;
}
.sub {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
word-break: break-all;
}
.taglist {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.x {
border: 0;
background: none;
cursor: pointer;
color: var(--muted);
padding: 0 0 0 4px;
}
.mins-row {
display: flex;
align-items: center;
gap: 10px;
}
.check {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
margin: 0;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--faint);
margin-right: 6px;
vertical-align: 2px;
}
.dot.published {
background: var(--accent);
}
.wide {
width: 100%;
}
.back {
display: inline-block;
margin-top: 14px;
font-size: 13px;
color: var(--accent);
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { adminApi, session } from '../api'
import { site } from '../site'
const router = useRouter()
const username = ref('admin')
const password = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
error.value = ''
busy.value = true
try {
const data = await adminApi.login(username.value, password.value)
session.user = username.value
void data
router.push('/admin')
} catch (e) {
error.value = '用户名或密码不对'
} finally {
busy.value = false
}
}
</script>
<template>
<div class="page">
<form class="card" @submit.prevent="submit">
<p class="eyebrow">后台</p>
<h1 class="title">登录 {{ site.site_title || 'ONE' }}</h1>
<div class="field">
<label for="u">用户名</label>
<input id="u" v-model="username" class="input" autocomplete="username" />
</div>
<div class="field">
<label for="p">密码</label>
<input
id="p"
v-model="password"
type="password"
class="input"
autocomplete="current-password"
@keyup.enter="submit"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn btn-primary" type="submit" :disabled="busy || !password">
{{ busy ? '登录中…' : '登录' }}
</button>
<p class="hint">
默认账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>。
</p>
<RouterLink to="/" class="back">← 回到前台</RouterLink>
</form>
</div>
</template>
<style scoped>
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.card {
width: 100%;
max-width: 380px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 4px;
padding: 28px;
}
.title {
font-size: 22px;
margin: 6px 0 22px;
}
.error {
color: #9a5b45;
font-size: 13px;
margin: -6px 0 14px;
}
.hint {
margin-top: 18px;
font-size: 12.5px;
line-height: 1.8;
color: var(--muted);
}
.hint code {
font-family: var(--mono);
font-size: 12px;
background: var(--paper-sunken);
padding: 1px 4px;
border-radius: 3px;
}
.back {
display: inline-block;
margin-top: 14px;
font-size: 13px;
color: var(--accent);
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils'
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const status = ref('')
const kind = ref('')
const q = ref('')
const page = ref(1)
const size = 20
async function load() {
loading.value = true
error.value = ''
try {
const params = { page: page.value, size }
if (status.value) params.status = status.value
if (kind.value) params.kind = kind.value
if (q.value.trim()) params.q = q.value.trim()
const data = await adminApi.posts(params)
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
watch([status, kind, page], load)
let searchTimer
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
load()
}, 300)
}
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
async function remove(id, title) {
if (!window.confirm(`删除《${title}》?此操作不可撤销。`)) return
try {
await adminApi.deletePost(id)
await load()
} catch (e) {
alert(e.message || '删除失败')
}
}
function edit(id) {
router.push(`/admin/${id}`)
}
</script>
<template>
<section>
<header class="head">
<h1 class="title">文章 <span class="count">{{ total }}</span></h1>
<RouterLink to="/admin/new" class="btn btn-primary">写新的</RouterLink>
</header>
<div class="filters">
<input v-model="q" class="input search" placeholder="搜索标题 / 摘要 / 正文" @input="onSearch" />
<select v-model="status" class="select">
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="draft">草稿</option>
</select>
<select v-model="kind" class="select">
<option value="">全部类型</option>
<option value="long">长文</option>
<option value="short">短文</option>
</select>
</div>
<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/new">写一篇 →</RouterLink>
</div>
<template v-else>
<table class="table">
<thead>
<tr>
<th class="c-title">标题</th>
<th class="c-kind">类型</th>
<th class="c-status">状态</th>
<th class="c-date">发布时间</th>
<th class="c-act"></th>
</tr>
</thead>
<tbody>
<tr v-for="p in items" :key="p.id">
<td class="c-title">
<a href="#" class="name" @click.prevent="edit(p.id)">
{{ p.title || '无题' }}
</a>
<span v-if="p.tags && p.tags.length" class="tags">
<span v-for="t in p.tags" :key="t" class="tag-chip">{{ t }}</span>
</span>
</td>
<td class="c-kind">
<span class="badge" :class="{ short: p.kind === 'short' }">
{{ p.kind === 'short' ? '短文' : '长文' }}
</span>
</td>
<td class="c-status">
<span class="dot" :class="p.status"></span>{{ p.status === 'published' ? '已发布' : '草稿' }}
</td>
<td class="c-date">
<span :title="formatDateShort(p.published_at)">{{ relativeDate(p.published_at) }}</span>
</td>
<td class="c-act">
<a href="#" @click.prevent="edit(p.id)">编辑</a>
<a v-if="p.status === 'published'" :href="`/post/${p.slug}`" target="_blank">查看</a>
<a href="#" class="danger" @click.prevent="remove(p.id, p.title)">删除</a>
</td>
</tr>
</tbody>
</table>
<nav v-if="pageCount > 1" class="pager">
<button class="btn" :disabled="page <= 1" @click="page = page - 1">← 上一页</button>
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="page = page + 1">下一页 →</button>
</nav>
</template>
</section>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title {
font-size: 22px;
}
.count {
font-family: var(--sans);
font-size: 13px;
color: var(--muted);
font-weight: 400;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 200px;
}
.select {
width: auto;
min-width: 120px;
}
.table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--line);
border-radius: 4px;
font-size: 14px;
}
.table th {
text-align: left;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.06em;
color: var(--muted);
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
.table tr:last-child td {
border-bottom: 0;
}
.table tr:hover td {
background: var(--paper-sunken);
}
.name {
font-size: 15px;
}
.tags {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 6px;
}
.badge {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--line);
border-radius: 2px;
font-size: 12px;
color: var(--muted);
}
.badge.short {
color: var(--accent);
border-color: var(--accent-line);
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--faint);
margin-right: 6px;
vertical-align: 2px;
}
.dot.published {
background: var(--accent);
}
.c-act {
white-space: nowrap;
text-align: right;
}
.c-act a {
margin-left: 12px;
font-size: 13px;
color: var(--accent);
}
.c-act a.danger {
color: #9a5b45;
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.page-info {
font-size: 13px;
color: var(--muted);
}
@media (max-width: 640px) {
.c-kind,
.c-date {
display: none;
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '../api'
import { site } from '../site'
const form = reactive({
site_title: '',
site_desc: '',
author_name: '',
author_bio: '',
footer_note: '',
icp: '',
posts_per_page: 10
})
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const ok = ref('')
onMounted(async () => {
try {
Object.assign(form, await adminApi.settings())
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
error.value = ''
ok.value = ''
try {
const saved = await adminApi.saveSettings({ ...form })
Object.assign(form, saved)
Object.assign(site, saved)
ok.value = '已保存'
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
</script>
<template>
<section>
<header class="head">
<h1 class="title">站点设置</h1>
</header>
<div v-if="loading" class="loading">载入中…</div>
<form v-else class="form" @submit.prevent="save">
<div class="field">
<label for="t">站点标题</label>
<input id="t" v-model="form.site_title" class="input" />
</div>
<div class="field">
<label for="d">站点简介</label>
<input id="d" v-model="form.site_desc" class="input" />
</div>
<div class="field">
<label for="a">作者名</label>
<input id="a" v-model="form.author_name" class="input" />
</div>
<div class="field">
<label for="b">作者简介</label>
<textarea id="b" v-model="form.author_bio" class="textarea" rows="3"></textarea>
</div>
<div class="field">
<label for="f">页脚信息</label>
<input id="f" v-model="form.footer_note" class="input" />
</div>
<div class="field">
<label for="i">备案号(可留空)</label>
<input id="i" v-model="form.icp" class="input" />
</div>
<div class="field">
<label for="p">每页文章数</label>
<input id="p" v-model.number="form.posts_per_page" type="number" min="1" max="100" class="input" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? '保存中…' : '保存' }}
</button>
</form>
</section>
</template>
<style scoped>
.head {
margin-bottom: 18px;
}
.title {
font-size: 22px;
}
.form {
max-width: 520px;
}
.error {
color: #9a5b45;
font-size: 13px;
}
.ok {
color: var(--accent);
font-size: 13px;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup>
import { onMounted, ref } from 'vue'
import { adminApi } from '../api'
const tags = ref([])
const loading = ref(true)
const error = ref('')
const name = ref('')
const editing = ref(null)
const editName = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const data = await adminApi.tags()
tags.value = data.tags || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
async function create() {
if (!name.value.trim()) return
try {
await adminApi.createTag(name.value.trim())
name.value = ''
await load()
} catch (e) {
error.value = e.message || '创建失败'
}
}
function startEdit(t) {
editing.value = t.id
editName.value = t.name
}
async function saveEdit(id) {
try {
await adminApi.renameTag(id, editName.value)
editing.value = null
await load()
} catch (e) {
error.value = e.message || '重命名失败'
}
}
async function remove(t) {
if (!window.confirm(`删除标签「${t.name}」?文章上的关联会一起移除。`)) return
try {
await adminApi.deleteTag(t.id)
await load()
} catch (e) {
error.value = e.message || '删除失败'
}
}
</script>
<template>
<section>
<header class="head">
<h1 class="title">标签</h1>
</header>
<form class="new" @submit.prevent="create">
<input v-model="name" class="input" placeholder="新标签名" />
<button class="btn btn-primary" type="submit">添加</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="!tags.length" class="empty">还没有标签。</div>
<ul v-else class="list">
<li v-for="t in tags" :key="t.id" class="row">
<template v-if="editing === t.id">
<input v-model="editName" class="input" @keyup.enter="saveEdit(t.id)" />
<button class="btn" @click="saveEdit(t.id)">保存</button>
<button class="btn" @click="editing = null">取消</button>
</template>
<template v-else>
<span class="name">{{ t.name }}</span>
<code class="slug">/tag/{{ t.slug }}</code>
<span class="count">{{ t.count }} 篇</span>
<span class="act">
<a href="#" @click.prevent="startEdit(t)">重命名</a>
<a href="#" class="danger" @click.prevent="remove(t)">删除</a>
</span>
</template>
</li>
</ul>
</section>
</template>
<style scoped>
.head {
margin-bottom: 16px;
}
.title {
font-size: 22px;
}
.new {
display: flex;
gap: 8px;
margin-bottom: 16px;
max-width: 420px;
}
.error {
color: #9a5b45;
font-size: 13px;
margin-bottom: 12px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
font-size: 14px;
}
.row:last-child {
border-bottom: 0;
}
.name {
min-width: 90px;
}
.slug {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
}
.count {
font-size: 12px;
color: var(--muted);
}
.act {
margin-left: auto;
display: flex;
gap: 14px;
}
.act a {
font-size: 13px;
color: var(--accent);
}
.act a.danger {
color: #9a5b45;
}
@media (max-width: 640px) {
.slug {
display: none;
}
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { adminApi, session } from '../api'
// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效
export async function checkAuth() {
try {
await adminApi.me()
return true
} catch (e) {
session.clear()
return false
}
}
+73
View File
@@ -0,0 +1,73 @@
const base = ''
async function request(path, { method = 'GET', body, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' }
const res = await fetch(base + path, {
method,
headers,
credentials: 'include',
body: body === undefined ? undefined : JSON.stringify(body)
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
if (!res.ok) {
let msg = `请求失败(${res.status})`
try {
const data = await res.json()
if (data && data.error) msg = data.error
} catch (_) {
/* 非 JSON 响应 */
}
const err = new Error(msg)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
export const publicApi = {
site: () => request('/api/site'),
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
archive: () => request('/api/archive'),
tags: () => request('/api/tags')
}
export const adminApi = {
login: (username, password) =>
request('/api/admin/login', { method: 'POST', body: { username, password } }),
logout: () => request('/api/admin/logout', { method: 'POST' }),
me: () => request('/api/admin/me'),
posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)),
post: (id) => request('/api/admin/posts/' + id),
createPost: (body) => request('/api/admin/posts', { method: 'POST', body }),
updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }),
deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }),
tags: () => request('/api/admin/tags'),
createTag: (name) => request('/api/admin/tags', { method: 'POST', body: { name } }),
renameTag: (id, name) => request('/api/admin/tags/' + id, { method: 'PUT', body: { name } }),
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
}
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
const USER_KEY = 'one.admin.user'
export const session = {
get user() {
return localStorage.getItem(USER_KEY) || ''
},
set user(v) {
if (v) localStorage.setItem(USER_KEY, v)
else localStorage.removeItem(USER_KEY)
},
clear() {
localStorage.removeItem(USER_KEY)
}
}
+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>
-16
View File
@@ -1,16 +0,0 @@
import { ref } from 'vue'
const dark = ref(document.documentElement.classList.contains('dark'))
/** 暗色模式:在 <html> 上切换 .dark 类并持久化到 localStorage */
export function useTheme() {
function setDark(value: boolean) {
dark.value = value
document.documentElement.classList.toggle('dark', value)
localStorage.setItem('theme', value ? 'dark' : 'light')
}
function toggle() {
setDark(!dark.value)
}
return { dark, setDark, toggle }
}
-8
View File
@@ -1,8 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
// 仪表盘布局:桌面端侧边栏 + 主内容;移动端顶栏 + 抽屉侧边栏
import { ref } from 'vue'
import DashboardSidebar from '@/components/dashboard/DashboardSidebar.vue'
import DashboardTopbar from '@/components/dashboard/DashboardTopbar.vue'
const mobileNavOpen = ref(false)
</script>
<template>
<div class="flex h-screen bg-slate-100">
<!-- 桌面端侧边栏占位 -->
<div class="w-sidebar shrink-0 hidden lg:block">
<DashboardSidebar />
</div>
<!-- 移动端抽屉 -->
<Transition name="drawer">
<div
v-if="mobileNavOpen"
class="fixed inset-y-0 left-0 z-40 lg:hidden shadow-modal"
>
<DashboardSidebar />
</div>
</Transition>
<Transition name="fade">
<div
v-if="mobileNavOpen"
class="fixed inset-0 z-30 bg-black/30 lg:hidden"
@click="mobileNavOpen = false"
/>
</Transition>
<!-- 移动端顶栏 -->
<DashboardTopbar @toggle="mobileNavOpen = !mobileNavOpen" />
<!-- 主内容区 -->
<div class="lg:p-3 size-full max-w-[calc(100vw-240px)] pt-16 lg:pt-0">
<div class="bg-white size-full lg:rounded-xl lg:drop-shadow overflow-y-auto">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
transform: translateX(-100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
-69
View File
@@ -1,69 +0,0 @@
<script setup lang="ts">
// 主页布局:固定 Header + 内容区 + Footer
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
import Logo from '@/components/common/Logo.vue'
import HomeTabs from '@/components/home/HomeTabs.vue'
import PromotionLinks from '@/components/home/PromotionLinks.vue'
</script>
<template>
<div class="bg-white min-h-screen flex flex-col">
<!-- Header -->
<header class="py-5 fixed w-full top-0 bg-white z-[2]">
<div class="max-w-screen-xl px-5 mx-auto flex justify-between items-center">
<!-- 左侧:Logo + 导航 -->
<div class="space-x-14 flex items-center">
<RouterLink to="/" class="text-2xl font-extrabold flex items-center">
<div class="inline-block size-9 mr-3">
<Logo :size="36" />
</div>
xLog
</RouterLink>
<div class="hidden sm:block">
<HomeTabs />
</div>
</div>
<!-- 右侧:连接钱包按钮 -->
<div class="space-x-14 text-zinc-500 flex items-center">
<button class="button is-black">Connect</button>
</div>
</div>
</header>
<!-- 内容区 -->
<section class="pt-24 flex-1">
<div class="max-w-screen-xl px-5 mx-auto flex">
<router-view />
</div>
</section>
<!-- Footer -->
<footer class="mt-10 font-medium border-t">
<div class="max-w-screen-xl px-5 py-14 mx-auto flex flex-col sm:flex-row justify-between">
<div class="w-full sm:w-72 space-y-4">
<PromotionLinks />
<div class="space-y-4">
<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>
</div>
<span
class="inline-flex items-center space-y-4 sm:space-y-0 sm:space-x-4 mx-auto sm:mx-0 mt-10 sm:mt-0 flex-col sm:flex-row"
>
<DarkModeSwitch />
<LanguageSwitch />
<span>
&copy; <RouterLink to="/" class="hover:text-accent">xLog</RouterLink>
</span>
</span>
</div>
</footer>
</div>
</template>
-15
View File
@@ -1,15 +0,0 @@
<script setup lang="ts">
// 站点布局:站点信息头 + 内容区 + 站点页脚
import SiteFooter from '@/components/site/SiteFooter.vue'
import SiteHeader from '@/components/site/SiteHeader.vue'
</script>
<template>
<div class="xlog-page bg-white min-h-screen flex flex-col">
<SiteHeader />
<main class="xlog-post-area max-w-screen-lg mx-auto px-5 pt-8 relative w-full flex-1">
<router-view />
</main>
<SiteFooter />
</div>
</template>
-28
View File
@@ -1,28 +0,0 @@
/** 数字格式化:1234 → 1.2k,1234567 → 1.2M */
export function formatNumber(n: number): string {
if (Number.isNaN(n)) return '0'
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
return String(n)
}
/** 相对时间:3 hours ago / 3 days ago */
export function formatTime(input: string | Date): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) return ''
const diff = Date.now() - date.getTime()
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return 'just now'
if (diff < hour) return `${Math.floor(diff / minute)} minutes ago`
if (diff < day) return `${Math.floor(diff / hour)} hours ago`
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`
return date.toLocaleDateString()
}
/** 估算阅读时长(按中文 ~300 字/分钟,英文 ~200 词/分钟粗略换算为字符数) */
export function estimateReadingTime(content: string): number {
const minutes = content.length / 400
return Math.max(1, Math.round(minutes))
}
+5 -12
View File
@@ -1,16 +1,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import './styles/main.css'
import { loadSite } from './site'
import './styles.css'
// 初始化暗色模式(优先本地存储,其次系统偏好)
const savedTheme = localStorage.getItem('theme')
const dark =
savedTheme === 'dark' ||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) {
document.documentElement.classList.add('dark')
}
createApp(App).use(router).mount('#app')
loadSite().then(() => {
createApp(App).use(router).mount('#app')
})
-402
View File
@@ -1,402 +0,0 @@
// Mock 数据:后端暂未实现,先以本地数据驱动页面
export interface Author {
handle: string
name: string
avatar: string
banner?: string
description?: string
followers?: number
}
export interface Post {
id: string
siteId: string
slug: string
title: string
excerpt: string
content: string
cover?: string
tags: string[]
views: number
comments: number
likes: number
date: string
author: Author
featured?: boolean
pinned?: boolean
status?: 'published' | 'draft' | 'scheduled'
}
export interface Comment {
id: string
author: Author
content: string
date: string
likes: number
}
export const avatar = (seed: string, size = 100) =>
`https://i.pravatar.cc/${size}?img=${seed}`
export const cover = (seed: string, w = 640, h = 360) =>
`https://picsum.photos/seed/${seed}/${w}/${h}`
const authors: Record<string, Author> = {
dirak: {
handle: 'dirak',
name: 'Di',
avatar: avatar('1'),
banner: cover('banner-dirak', 1024, 300),
description: 'Building a more connected, open, and elegant world.',
followers: 1284,
},
she: {
handle: 'she',
name: 'She',
avatar: avatar('2'),
banner: cover('banner-she', 1024, 300),
description: 'Frontend engineer. Loves Vue, design systems & coffee.',
followers: 932,
},
kyoko: {
handle: 'kyoko',
name: 'Kyoko',
avatar: avatar('3'),
banner: cover('banner-kyoko', 1024, 300),
description: 'Indie hacker writing about web3, notes & life.',
followers: 2107,
},
vincent: {
handle: 'vincent',
name: 'Vincent',
avatar: avatar('4'),
banner: cover('banner-vincent', 1024, 300),
description: 'Photographer & blogger. Film everywhere.',
followers: 566,
},
aki: {
handle: 'aki',
name: 'Aki',
avatar: avatar('5'),
banner: cover('banner-aki', 1024, 300),
description: 'Full-stack dev. Open source enthusiast.',
followers: 1490,
},
}
export const sites: Author[] = Object.values(authors)
const posts: Post[] = [
{
id: 'p1',
siteId: 'dirak',
slug: 'welcome-to-xlog',
title: 'Welcome to xLog — a new home for your blog',
excerpt:
'xLog is a decentralized blog platform built on Crossbell. It gives every creator a permanent home on the open web.',
content: `# Welcome to xLog
This is an **example post** rendered from Markdown. xLog is a blog platform built on Crossbell that gives every creator a permanent home on the open web.
> Share your words with the world, and own them forever.
## Why we built it
- **Own your content** — everything lives on the blockchain.
- **Open & connected** — follow, comment, tip across sites.
- **Beautiful by default** — a clean reading experience.
## Getting started
\`\`\`bash
npx create-xlog
\`\`\`
Check out the [docs](https://xlog.app) for more.`,
cover: cover('welcome', 640, 360),
tags: ['intro', 'web3'],
views: 1234,
comments: 18,
likes: 42,
date: '2026-07-28',
author: authors.dirak,
featured: true,
},
{
id: 'p2',
siteId: 'dirak',
slug: 'designing-with-css-variables',
title: 'Designing with CSS variables and the modern dark mode',
excerpt:
'CSS variables make theming feel effortless. Here is how we keep the entire UI consistent with just a few custom properties.',
content: `# Designing with CSS variables
CSS custom properties are a superpower for theming. With a handful of variables you can drive the entire visual language of an app.
\`\`\`css
html {
--theme-color: #f97316;
--border-color: #eee;
}
\`\`\`
## Dark mode made easy
Toggle a class on \`<html>\`, and the variables re-resolve:
\`\`\`css
html.dark {
--theme-color: #ea580c;
--border-color: #333;
}
\`\`\`
That is the whole trick.`,
cover: cover('cssvar', 640, 360),
tags: ['css', 'design'],
views: 892,
comments: 7,
likes: 31,
date: '2026-07-21',
author: authors.dirak,
},
{
id: 'p3',
siteId: 'dirak',
slug: 'building-a-blog-on-crossbell',
title: 'Building a blog on Crossbell: notes from the field',
excerpt:
'A practical walkthrough of publishing posts, claiming CSB tokens, and connecting with readers on the open network.',
content: `# Building on Crossbell
Crossbell gives you the primitives to build a fully on-chain blog. Let's walk through the key steps.
1. Create a character
2. Publish posts
3. Claim CSB
4. Connect with readers`,
cover: cover('crossbell', 640, 360),
tags: ['tutorial', 'crossbell'],
views: 2011,
comments: 24,
likes: 87,
date: '2026-07-14',
author: authors.dirak,
featured: true,
},
{
id: 'p4',
siteId: 'she',
slug: 'vue-3-composition-api-guide',
title: 'A practical guide to Vue 3 Composition API',
excerpt:
'Stop copying snippets and start understanding setup functions, refs, and computed. A guide for busy developers.',
content: `# Vue 3 Composition API
The Composition API gives you better code organization and reusability.
\`\`\`ts
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
\`\`\`
That's the essence.`,
cover: cover('vue3', 640, 360),
tags: ['vue', 'frontend'],
views: 1750,
comments: 12,
likes: 64,
date: '2026-07-25',
author: authors.she,
},
{
id: 'p5',
siteId: 'she',
slug: 'tailwind-vs-css-modules',
title: 'Tailwind vs CSS Modules: what we actually use',
excerpt:
'A pragmatic comparison from someone who has shipped both. Spoiler: the answer is "it depends" — but here is our default.',
content: `# Tailwind vs CSS Modules
Both are great. We default to Tailwind for speed and consistency, with CSS modules reserved for truly isolated components.`,
cover: cover('tailwind', 640, 360),
tags: ['css', 'tailwind'],
views: 643,
comments: 5,
likes: 22,
date: '2026-07-18',
status: 'draft',
author: authors.she,
},
{
id: 'p6',
siteId: 'kyoko',
slug: 'on-chain-blogging',
title: 'Why I moved my blog on-chain',
excerpt:
'Censorship resistance, portability, and an open social graph — the reasons that finally convinced me to leave the walled gardens.',
content: `# Why on-chain?
When your words live on a blockchain, no platform can take them away.
- Censorship resistant
- Portable across frontends
- Own your social graph
It's not for everyone, but it is for writers who care about permanence.`,
cover: cover('onchain', 640, 360),
tags: ['web3', 'opinion'],
views: 3310,
comments: 45,
likes: 132,
date: '2026-07-22',
author: authors.kyoko,
featured: true,
},
{
id: 'p7',
siteId: 'kyoko',
slug: 'indie-hacking-notes',
title: 'Indie hacking: three months of shipping in public',
excerpt:
'Revenue numbers, mental models, and the mistakes I will not repeat. An honest recap of a quarter spent building in public.',
content: `# Three months of shipping in public
Here's what worked, what didn't, and what I'd do differently.
## Wins
- Launched the MVP in 6 weeks
- 400 signups from a single post
## Losses
- Churned a third of early users
- Spent too long polishing internals`,
cover: cover('indie', 640, 360),
tags: ['indie', 'business'],
views: 2214,
comments: 28,
likes: 95,
date: '2026-07-10',
author: authors.kyoko,
},
{
id: 'p8',
siteId: 'vincent',
slug: 'film-vs-digital',
title: 'Film vs digital: a photographer confession',
excerpt:
'After a decade of shooting digital, I picked up a film camera. Here is what it taught me about slowing down.',
content: `# Film vs digital
Shooting film changed how I see. Twenty exposures per roll means every frame matters.
> Less is more — sometimes.`,
cover: cover('film', 640, 360),
tags: ['photography'],
views: 987,
comments: 9,
likes: 41,
date: '2026-07-19',
status: 'draft',
author: authors.vincent,
},
{
id: 'p9',
siteId: 'vincent',
slug: 'city-of-neon',
title: 'City of neon: a night photography walkthrough',
excerpt:
'Tips for shooting neon signs at night: exposure, white balance, and finding the light in the rain.',
content: `# City of neon
Night photography is a game of patience and light.
- Bring a tripod
- Meter for highlights
- Embrace the rain for reflections`,
cover: cover('neon', 640, 360),
tags: ['photography', 'city'],
views: 1455,
comments: 11,
likes: 58,
date: '2026-07-05',
author: authors.vincent,
},
{
id: 'p10',
siteId: 'aki',
slug: 'open-source-lessons',
title: 'Lessons from maintaining an open source project',
excerpt:
'Maintainer burn-out, issue triage, and why saying "no" is a feature — everything I learned the hard way.',
content: `# Maintaining open source
Maintenance is where most projects fail, not inception.
1. Triage ruthlessly
2. Document everything
3. Automate the boring parts
Saying "no" is a feature, not a failure.`,
cover: cover('oss', 640, 360),
tags: ['opensource'],
views: 1766,
comments: 15,
likes: 71,
date: '2026-06-30',
author: authors.aki,
},
{
id: 'p11',
siteId: 'aki',
slug: 'serverless-edge',
title: 'Serverless at the edge: is it worth the hype?',
excerpt:
'Cold starts, cost surprises, and when edge functions genuinely beat a plain old server.',
content: `# Serverless at the edge
The edge is great — until it isn't.
Use it for: personalization, geo-routing, caching.
Skip it for: long-running tasks, heavy compute.`,
cover: cover('edge', 640, 360),
tags: ['backend', 'serverless'],
views: 1088,
comments: 13,
likes: 49,
date: '2026-06-25',
status: 'scheduled',
author: authors.aki,
},
]
export const allPosts: Post[] = posts
export const getPost = (siteId: string, slug: string) =>
posts.find((p) => p.siteId === siteId && p.slug === slug)
export const comments: Comment[] = [
{
id: 'c1',
author: authors.she,
content:
'Great writeup! The dark mode section was exactly what I needed. Thanks for sharing.',
date: '2026-07-29',
likes: 3,
},
{
id: 'c2',
author: authors.kyoko,
content: 'Bookmarked this. CSS variables are so underrated.',
date: '2026-07-28',
likes: 5,
},
{
id: 'c3',
author: authors.vincent,
content: 'Could you share the full config file? Would love to reference it.',
date: '2026-07-27',
likes: 1,
},
]
+33
View File
@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'home', component: () => import('./views/HomeView.vue') },
{ path: '/post/:slug', name: 'post', component: () => import('./views/PostView.vue') },
{ path: '/archive', name: 'archive', component: () => import('./views/ArchiveView.vue') },
{ path: '/tags', name: 'tags', component: () => import('./views/TagsView.vue') },
{ path: '/tag/:slug', name: 'tag', component: () => import('./views/TagView.vue') },
{ path: '/about', name: 'about', component: () => import('./views/AboutView.vue') },
{ path: '/admin/login', name: 'admin-login', component: () => import('./admin/LoginView.vue') },
{
path: '/admin',
component: () => import('./admin/AdminLayout.vue'),
children: [
{ path: '', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
]
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('./views/NotFoundView.vue') }
]
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, saved) {
return saved || { top: 0 }
}
})
-89
View File
@@ -1,89 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import HomeLayout from '@/layouts/HomeLayout.vue'
import SiteLayout from '@/layouts/SiteLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
// 主页布局:Header + Footer
path: '/',
component: HomeLayout,
children: [
{ path: '', name: 'home', component: () => import('@/views/HomePage.vue') },
{ path: 'shorts', name: 'shorts', component: () => import('@/views/HomePage.vue') },
{ path: 'latest', name: 'latest', component: () => import('@/views/HomePage.vue') },
{ path: 'hottest', name: 'hottest', component: () => import('@/views/HomePage.vue') },
{ path: 'following', name: 'following', component: () => import('@/views/HomePage.vue') },
{ path: 'about', name: 'about', component: () => import('@/views/AboutPage.vue') },
{ path: 'search', name: 'search', component: () => import('@/views/SearchPage.vue') },
],
},
{
// 站点布局:站点头 + 文章内容
path: '/',
component: SiteLayout,
children: [
{
path: 'post/:site/:slug',
name: 'post',
component: () => import('@/views/PostDetail.vue'),
},
{
path: 'site/:site',
name: 'site',
component: () => import('@/views/SiteHome.vue'),
},
{
path: 'site/:site/archives',
name: 'site-archives',
component: () => import('@/views/SiteArchives.vue'),
},
],
},
{
// 仪表盘布局:侧边栏 + 主内容
path: '/dashboard',
component: DashboardLayout,
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'posts',
name: 'dashboard-posts',
component: () => import('@/views/PostsManager.vue'),
},
{
path: 'pages',
name: 'dashboard-pages',
component: () => import('@/views/PagesManager.vue'),
},
{
path: 'editor',
name: 'dashboard-editor',
component: () => import('@/views/EditorPlaceholder.vue'),
},
{
path: 'settings/:tab?',
name: 'dashboard-settings',
component: () => import('@/views/SettingsGeneral.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFound.vue'),
},
],
scrollBehavior() {
return { top: 0 }
},
})
export { router }
+29
View File
@@ -0,0 +1,29 @@
import { reactive } from 'vue'
import { publicApi } from './api'
export const site = reactive({
site_title: 'ONE · 一个博客',
site_desc: '长文与短文,同一种节奏。',
author_name: 'ONE',
author_bio: '写点长的,也写点短的。',
footer_note: '© ONE · 一个博客',
icp: '',
posts_per_page: 10,
loaded: false
})
export async function loadSite() {
try {
Object.assign(site, await publicApi.site())
site.loaded = true
} catch (e) {
// 站点还没起来时用默认值,不阻塞首屏
site.loaded = false
}
return site
}
export function applyDocTitle(sub) {
const base = site.site_title || 'ONE'
document.title = sub ? `${sub} · ${base}` : base
}
+443
View File
@@ -0,0 +1,443 @@
/* ONE — 设计 token(沿用 ROOT-2 第 7 套「融合 + Twitter 信息流」) */
:root {
/* 皮肤:米白纸感 */
--paper: #faf7f1;
--card: #fffdf8;
--paper-sunken: #f3efe6;
/* 墨色:正文暖灰,不用纯黑 */
--ink: #33302b;
--ink-soft: #5c564d;
--muted: #8a8378;
--faint: #a9a297;
/* 强调色:降饱和蓝(只出现在少数几处) */
--accent: #3d7f9c;
--accent-soft: rgba(61, 127, 156, 0.1);
--accent-line: rgba(61, 127, 156, 0.35);
/* 细线:纸感的分割靠 1px 线,不靠阴影 */
--line: #e6e0d4;
--line-soft: #efe9dd;
/* 字体:标题衬线,正文黑体 */
--serif: Georgia, "Times New Roman", "Songti SC", "Noto Serif SC", "Source Han Serif SC", serif;
--sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
--mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--lh: 1.95;
/* 三栏骨架 */
--col-left: 236px;
--col-main: 640px;
--col-right: 306px;
--gutter: 28px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: var(--lh);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* 移动端不留横向滚动 */
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--accent);
}
img {
max-width: 100%;
}
button,
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
h1,
h2,
h3,
h4 {
font-family: var(--serif);
font-weight: 600;
line-height: 1.4;
margin: 0;
letter-spacing: 0.01em;
}
::selection {
background: rgba(61, 127, 156, 0.18);
}
/* ---------- 通用容器 ---------- */
.shell {
max-width: 1238px;
margin: 0 auto;
padding: 0 20px;
}
.layout {
display: grid;
grid-template-columns: var(--col-left) minmax(0, var(--col-main)) var(--col-right);
gap: var(--gutter);
align-items: start;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: var(--col-left) minmax(0, 1fr);
}
.layout > .rail-right {
display: none;
}
}
@media (max-width: 780px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.layout > .rail-left {
display: none;
}
}
/* ---------- 小组件 ---------- */
.eyebrow {
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.divider {
height: 1px;
background: var(--line);
border: 0;
margin: 0;
}
.muted {
color: var(--muted);
}
.mono {
font-family: var(--mono);
font-size: 13px;
}
.tag-chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
line-height: 1.8;
color: var(--ink-soft);
background: transparent;
}
.tag-chip:hover {
border-color: var(--accent-line);
color: var(--accent);
}
.pill {
display: inline-block;
padding: 7px 18px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 14px;
border: 1px solid var(--accent);
cursor: pointer;
}
.pill:hover {
background: #356f88;
color: #fff;
}
.pill-ghost {
background: transparent;
color: var(--accent);
}
.pill-ghost:hover {
background: var(--accent-soft);
color: var(--accent);
}
/* ---------- 表单 ---------- */
.field {
margin-bottom: 18px;
}
.field > label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
.input,
.textarea,
.select {
width: 100%;
padding: 9px 12px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 3px;
outline: none;
}
.input:focus,
.textarea:focus,
.select:focus {
border-color: var(--accent-line);
}
.textarea {
resize: vertical;
}
.btn {
padding: 8px 18px;
border: 1px solid var(--line);
background: var(--card);
border-radius: 3px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent-line);
color: var(--accent);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: #356f88;
color: #fff;
}
.btn-danger:hover {
border-color: #b4553f;
color: #b4553f;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- 正文排版(长文:首字下沉 + 1.95 行高) ---------- */
.prose {
font-size: 16.5px;
line-height: var(--lh);
color: var(--ink);
}
.prose > :first-child {
margin-top: 22px;
}
.prose p {
margin: 0 0 1.1em;
}
/* 首段首字下沉 */
.prose > p:first-of-type::first-letter {
float: left;
font-family: var(--serif);
font-size: 52px;
line-height: 1;
margin: 6px 10px 0 0;
color: var(--accent);
}
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
font-family: var(--serif);
color: var(--ink);
margin: 1.8em 0 0.6em;
}
.prose h1 {
font-size: 26px;
}
.prose h2 {
font-size: 22px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.prose h3 {
font-size: 18px;
}
.prose h3::after {
content: '';
display: block;
width: 28px;
height: 2px;
margin-top: 8px;
background: var(--accent);
}
.prose h4 {
font-size: 16.5px;
}
/* 引言用衬线,和正文区分开 */
.prose blockquote {
margin: 1.4em 0;
padding: 2px 0 2px 18px;
border-left: 2px solid var(--accent-line);
font-family: var(--serif);
color: var(--ink-soft);
}
.prose blockquote p:last-child {
margin-bottom: 0;
}
.prose ul,
.prose ol {
margin: 0 0 1.1em;
padding-left: 1.4em;
}
.prose li {
margin-bottom: 0.35em;
}
.prose a {
color: var(--accent);
border-bottom: 1px solid var(--accent-line);
}
.prose a:hover {
border-bottom-color: var(--accent);
}
.prose code {
font-family: var(--mono);
font-size: 13.5px;
background: var(--paper-sunken);
padding: 1px 5px;
border-radius: 3px;
}
.prose pre {
background: var(--paper-sunken);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.7;
}
.prose pre code {
background: none;
padding: 0;
font-size: 13.5px;
}
.prose img {
border-radius: 3px;
display: block;
}
.prose hr {
height: 1px;
border: 0;
background: var(--line);
margin: 2em 0;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.4em 0;
font-size: 15px;
}
.prose th,
.prose td {
border: 1px solid var(--line);
padding: 7px 10px;
text-align: left;
}
.prose th {
background: var(--paper-sunken);
font-weight: 600;
}
/* 短文:放大正文,不做首字下沉 */
.prose-short {
font-size: 20px;
line-height: 1.9;
}
.prose-short > p:first-of-type::first-letter {
float: none;
font-size: inherit;
line-height: inherit;
margin: 0;
color: inherit;
}
/* ---------- 空状态 / 加载 ---------- */
.empty {
padding: 48px 0;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.loading {
padding: 48px 0;
text-align: center;
color: var(--muted);
font-size: 14px;
}
-12
View File
@@ -1,12 +0,0 @@
@import './variables.css';
@import './tailwind.css';
@import './prose.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* body 基础 */
body {
@apply text-zinc-900;
}
-74
View File
@@ -1,74 +0,0 @@
/* 文章排版 */
@layer components {
.prose {
@apply text-zinc-600 leading-loose;
word-break: break-word;
}
.prose > p {
@apply mb-5;
}
.prose h2 {
@apply text-3xl font-semibold my-7 text-zinc-900;
}
.prose h3 {
@apply text-2xl font-semibold my-7 text-zinc-900;
}
.prose h4 {
@apply text-xl font-semibold my-7 text-zinc-900;
}
.prose h1 {
@apply text-4xl font-bold my-8 text-zinc-900;
}
.prose blockquote {
@apply border-l-4 border-zinc-300 pl-5 my-3 text-zinc-500;
}
.prose a {
@apply text-accent hover:underline;
}
.prose ul {
@apply list-disc pl-5 mb-5;
}
.prose ol {
@apply list-decimal pl-6 mb-5;
}
.prose li {
@apply mb-1;
}
.prose img {
@apply inline-block;
}
.prose code {
@apply bg-zinc-100 rounded px-1 py-0.5 text-sm font-mono;
}
.prose pre {
@apply bg-zinc-900 text-zinc-100 rounded-xl p-4 my-5 overflow-x-auto text-sm;
}
.prose pre code {
@apply bg-transparent p-0 text-inherit;
}
.prose hr {
@apply border-zinc-200 my-8;
}
.prose table {
@apply w-full text-left text-sm border-collapse my-5;
}
.prose th {
@apply border-b border-zinc-200 py-2 pr-4 font-semibold text-zinc-900;
}
.prose td {
@apply border-b border-zinc-200 py-2 pr-4;
}
/* 代码块包裹 */
.prose .code-wrapper {
@apply relative text-base;
}
.prose .code-wrapper .copy-button {
@apply absolute top-3 right-3 hidden text-sm space-x-1;
@apply text-zinc-100 bg-zinc-600/50 rounded-lg h-7 px-3 items-center;
}
.prose .code-wrapper:hover .copy-button {
@apply inline-flex;
}
}
-88
View File
@@ -1,88 +0,0 @@
/* 通用组件类:按钮 / 输入框 / 隐藏滚动条 */
@layer components {
/* 按钮基础样式 */
.button {
@apply inline-flex items-center justify-center h-9 px-5 min-w-[100px];
@apply whitespace-nowrap font-medium transition active:scale-95;
@apply focus-visible:outline focus-visible:outline-accent;
}
.button.is-primary {
@apply text-white bg-accent opacity-90 hover:opacity-100;
}
.button.is-secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-500;
}
.button.is-text {
@apply shadow-none py-0 px-3 min-w-0 hover:bg-hover;
}
.button.is-sm {
@apply h-7 px-3 text-sm min-w-[auto];
}
.button.is-xl {
@apply h-10 px-6 text-xl;
}
.button.is-block {
@apply w-full;
}
.button.is-loading {
@apply opacity-50;
}
.button.is-black {
@apply bg-black hover:bg-white focus:bg-white;
@apply text-white hover:text-black focus:text-black;
@apply border-black border;
}
.button.is-like {
@apply hover:bg-[#f91880]/10 hover:text-[#f91880];
}
.button.is-collect {
@apply hover:bg-[#ffcf55]/10 hover:text-[#ffcf55];
}
.button.is-tip {
@apply hover:bg-[#facc15]/10 hover:text-[#facc15];
}
.button.is-share {
@apply hover:bg-[#0ea5e9]/10 hover:text-[#0ea5e9];
}
.button.is-comment {
@apply hover:bg-green-500/10 hover:text-green-500;
}
.button.is-outline {
@apply border-black text-black border;
}
.button.is-2xl {
@apply h-12 px-6 text-xl;
}
/* 按钮组 */
.button-group {
@apply flex items-center;
}
.button-group .button:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.button-group .button:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* 输入框 */
.input {
@apply border outline-none rounded-lg px-3 h-10 inline-flex items-center;
@apply focus:ring-1 focus:border-accent;
--tw-ring-color: var(--theme-color);
}
.input.is-block {
@apply w-full;
}
/* 隐藏横向滚动条(Tabs 容器) */
.scrollbar-hide {
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
-27
View File
@@ -1,27 +0,0 @@
html {
--border-color: #eee;
--theme-color: var(--auto-theme-color, #f97316); /* orange-500 */
--hover-color: var(--auto-hover-color, #f4f4f5); /* zinc-100 */
--header-height: auto;
accent-color: var(--theme-color);
-webkit-tap-highlight-color: transparent;
scrollbar-color: var(--theme-color) transparent;
scrollbar-width: thin;
}
html.dark {
--border-color: #333;
--theme-color: var(--auto-theme-color, #ea580c); /* orange-600 */
}
/* 暗色模式下颜色映射 */
html.dark body {
--tw-color-zinc-400: var(--tw-color-zinc-500);
--tw-color-zinc-500: var(--tw-color-zinc-600);
--tw-color-zinc-600: var(--tw-color-zinc-700);
}
::selection {
background-color: var(--theme-color);
color: #fff;
}
+70
View File
@@ -0,0 +1,70 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({ breaks: true, gfm: true })
// 编辑器预览与前台渲染共用这一个入口:先 marked 渲染,再过 DOMPurify
export function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || '', { async: false }))
}
// 编辑器按 `md.render(...)` 的用法调用
export const md = { render: renderMarkdown }
export function formatDate(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y} 年 ${m} 月 ${day} 日`
}
export function formatDateShort(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`
}
// 时间线上的相对时间:三天内用「几小时前」,同年省略年份
export function relativeDate(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const diff = Date.now() - d.getTime()
const hour = 3600 * 1000
if (diff < hour) return `${Math.max(1, Math.floor(diff / 60000))} 分钟前`
if (diff < 24 * hour) return `${Math.floor(diff / hour)} 小时前`
if (diff < 3 * 24 * hour) return `${Math.floor(diff / (24 * hour))} 天前`
const sameYear = d.getFullYear() === new Date().getFullYear()
return sameYear
? `${d.getMonth() + 1} 月 ${d.getDate()} 日`
: `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
}
export function stripTags(html) {
const div = document.createElement('div')
div.innerHTML = html || ''
return div.textContent || ''
}
// 短文在列表里没有标题,用正文首句当索引
export function displayTitle(post) {
if (post.kind === 'short') return ''
return post.title || '无题'
}
export function excerpt(post, limit = 60) {
if (post.summary) return post.summary
const text = stripTags(post.content_html || '')
return text.length > limit ? text.slice(0, limit) + '…' : text
}
export function minutesLabel(post) {
if (post.kind === 'short') return '短文'
return `${post.reading_minutes || 1} 分钟`
}
-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>