安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试

后端:
- ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试
- 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器
- Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限
- 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB

前端:
- EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章)
- v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser
- 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复
- 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复
- 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
This commit is contained in:
Sakurasan
2026-09-21 23:59:09 +08:00
parent c762f06cd7
commit dd2994189a
25 changed files with 539 additions and 1411 deletions
+2 -8
View File
@@ -7,15 +7,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
"preview": "vite preview"
},
"dependencies": {
"@egoist/tailwindcss-icons": "^1.8.0",
"@iconify-json/mingcute": "^1.1.17",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6",
"marked": "^12.0.2",
"vue": "^3.4.0",
"vue-router": "^4.4.0"
},
@@ -23,10 +19,8 @@
"@vitejs/plugin-vue": "^5.1.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.3",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
"vite": "^5.4.0"
},
"pnpm": {
"onlyBuiltDependencies": [
-729
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+6 -3
View File
@@ -38,10 +38,12 @@ onMounted(async () => {
})
// 会话过期时(请求返回 401)统一跳回登录页
window.addEventListener('one:unauthorized', () => {
function onUnauthorized() {
session.clear()
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
})
}
onMounted(() => window.addEventListener('one:unauthorized', onUnauthorized))
onBeforeUnmount(() => window.removeEventListener('one:unauthorized', onUnauthorized))
async function logout() {
try {
@@ -149,7 +151,8 @@ watch(() => route.path, () => loadCounts())
</div>
</header>
<main class="admin-content">
<RouterView />
<!-- 编辑器在 /admin/:id 间切换会复用组件:按 id 重建实例,防止串写 -->
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
</main>
</div>
+12 -1
View File
@@ -87,6 +87,10 @@ function onKey(e) {
e.preventDefault()
const it = flat.value[idx.value]
if (it) run(it.item)
} else if (e.key === 'Tab') {
// 面板内只有一个输入框,Tab 不外逃
e.preventDefault()
inputEl.value?.focus()
}
}
@@ -107,9 +111,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
v-model="query"
placeholder="输入命令、文章标题…"
aria-label="搜索命令和文章"
role="combobox"
aria-expanded="true"
aria-controls="cmd-listbox"
:aria-activedescendant="flat.length ? 'cmd-opt-' + idx : undefined"
@input="idx = 0"
/>
<div class="cmd-list">
<div class="cmd-list" id="cmd-listbox" role="listbox">
<template v-for="(sec, si) in sections" :key="si">
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
{{ sec.name }}
@@ -119,6 +127,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:key="it.id"
class="row"
:class="{ on: idx === it._idx }"
role="option"
:id="'cmd-opt-' + it._idx"
:aria-selected="idx === it._idx"
@click="run(it)"
@mouseenter="idx = it._idx"
>
+21 -14
View File
@@ -5,7 +5,6 @@ import { adminApi } from '../api'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { marked } from 'marked'
const route = useRoute()
const router = useRouter()
@@ -136,7 +135,8 @@ async function initEditor() {
root: editorEl.value,
defaultValue: form.content_md,
features: {
[CrepeFeature.AI]: false
[CrepeFeature.AI]: false,
[CrepeFeature.Latex]: false
},
featureConfigs: {
[CrepeFeature.Placeholder]: {
@@ -173,6 +173,8 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
clearTimeout(timer)
if (stopWatch) stopWatch()
if (crepe) {
crepe.destroy()
crepe = null
@@ -195,7 +197,7 @@ async function switchMode(next) {
crepe = new Crepe({
root: editorEl.value,
defaultValue: form.content_md,
features: { [CrepeFeature.AI]: false },
features: { [CrepeFeature.AI]: false, [CrepeFeature.Latex]: false },
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
@@ -213,13 +215,6 @@ async function switchMode(next) {
mode.value = next
}
watch(mode, (n) => {
// ensure form is always the source of truth
if (n === 'md' && crepe) {
// last sync already happened via listener
}
})
// ---------- 自动保存 ----------
let timer
@@ -270,6 +265,21 @@ function applySaved(saved) {
publishedLocal.value = isoToLocal(saved.published_at)
}
// /admin/1 → /admin/2 时组件被复用(随后 :key 触发重建)。
// 必须 sync:父级换 key 会先销毁本实例,pre-flush 回调来不及跑;
// 这里取消旧定时器并把未落盘改动写回原文章 id,防止串写
watch(
id,
(next, prev) => {
clearTimeout(timer)
if (stopWatch) stopWatch()
if (prev > 0 && Number.isFinite(next) && next > 0 && dirty.value) {
adminApi.updatePost(prev, buildPayload()).catch(() => {})
}
},
{ flush: 'sync' }
)
function buildPayload() {
return {
kind: form.kind,
@@ -373,9 +383,6 @@ function clearCover() {
form.cover_url = ''
}
// preview mode — render markdown to HTML for the side panel
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
@@ -578,7 +585,7 @@ const tbGroups = computed(() => [
</div>
</header>
<p v-if="error" style="color: #b4553f; font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
<p v-if="error" style="color: var(--admin-danger); font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
<div class="editor-grid">
<div>
+1 -1
View File
@@ -5,7 +5,7 @@ import { adminApi, session } from '../api'
import { site } from '../site'
const router = useRouter()
const username = ref('admin')
const username = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
+8 -2
View File
@@ -31,7 +31,11 @@ const orderMap = {
longest: 'reading_minutes DESC'
}
// 快速切换筛选/翻页时只采纳最新一次请求的结果(序号防竞态覆盖)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
@@ -41,13 +45,15 @@ async function load() {
if (tagFilter.value) params.tag = tagFilter.value
if (q.value.trim()) params.q = q.value.trim()
const data = await adminApi.posts(params)
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
selected.value = new Set()
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
} finally {
loading.value = false
if (my === seq) loading.value = false
}
}
@@ -264,7 +270,7 @@ function clearFilters() {
</div>
<div class="stats" aria-hidden="true">
<div>{{ p.reading_minutes || 1 }}′</div>
<div>{{ (p.content_md || '').length }} 字</div>
<div>{{ p.content_len || 0 }} 字</div>
</div>
<div class="actions">
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>
+20 -1
View File
@@ -1,5 +1,23 @@
const base = ''
// 侧栏数据(标签 / 最近更新)每页都会重新挂载请求,这里做 30s 的
// 模块级缓存 + 并发去重;失败不缓存,下一次调用会重新请求。
function cached(fn, ttl = 30000) {
let p = null
let at = 0
return (...args) => {
const now = Date.now()
if (p && now - at < ttl) return p
at = now
const cur = fn(...args)
p = cur
cur.catch(() => {
if (p === cur) p = null
})
return cur
}
}
async function request(path, { method = 'GET', body, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' }
const res = await fetch(base + path, {
@@ -35,7 +53,8 @@ export const publicApi = {
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
archive: () => request('/api/archive'),
tags: () => request('/api/tags')
tags: cached(() => request('/api/tags')),
latest: cached(() => request('/api/posts?size=5'))
}
export const adminApi = {
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import { relativeDate, stripTags, minutesLabel } from '../utils'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
const props = defineProps({
post: { type: Object, required: true }
@@ -12,7 +12,7 @@ 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 || ''
return sanitizeHtml(props.post.content_html)
})
// 长文没写摘要时,从正文里截一段纯文本
+1 -4
View File
@@ -9,10 +9,7 @@ const tags = ref([])
onMounted(async () => {
try {
const [posts, tagData] = await Promise.all([
publicApi.posts({ size: 5 }),
publicApi.tags()
])
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
latest.value = posts.items || []
tags.value = (tagData.tags || []).slice(0, 12)
} catch (e) {
+8 -501
View File
@@ -534,504 +534,6 @@ h4 {
font-size: 14px;
}
/* ---------- 主题:前台视图响应 site.theme_id ----------
后台自己的 token(--admin-*)保持米白纸感不变。
切换 [data-theme] 即可换肤;默认 paper 不写在选择器里。 */
:root[data-theme='ink'] {
--paper: #1f1d1a;
--card: #25221e;
--paper-sunken: #2b2722;
--ink: #ece5d2;
--ink-soft: #c3b58f;
--muted: #8c8270;
--faint: #6a6155;
--line: #3a352e;
--line-soft: #2f2b25;
--accent: #c3a972;
--accent-soft: rgba(195, 169, 114, 0.14);
--accent-line: rgba(195, 169, 114, 0.4);
}
:root[data-theme='sage'] {
--paper: #eef0e6;
--card: #f6f7ef;
--paper-sunken: #e2e6d6;
--ink: #2a3127;
--ink-soft: #475042;
--muted: #6f7a68;
--faint: #94a08d;
--line: #d6dcc9;
--line-soft: #e6e9d9;
--accent: #5f7b5c;
--accent-soft: rgba(95, 123, 92, 0.12);
--accent-line: rgba(95, 123, 92, 0.38);
}
:root[data-theme='rose'] {
--paper: #f7eee6;
--card: #fdf5ee;
--paper-sunken: #ecdfd3;
--ink: #3a2922;
--ink-soft: #5e463d;
--muted: #9a7c6f;
--faint: #b89c91;
--line: #ead9cb;
--line-soft: #f0e2d5;
--accent: #a55a4a;
--accent-soft: rgba(165, 90, 74, 0.1);
--accent-line: rgba(165, 90, 74, 0.38);
}
/* ---------- 后台主题:跟随访客 mode 切换明暗 ----------
data-admin-theme 与 data-theme 是两套轴:data-theme 控制前台皮肤,
data-admin-theme 只切后台的明/暗。后台暗色用深棕墨感,跟前台 ink 协调。 */
:root[data-admin-theme='dark'] {
--admin-bg: #1a1815;
--admin-paper: var(--admin-bg);
--admin-card: #25221e;
--admin-paper-sunken: #2f2b25;
--admin-ink: #ece5d2;
--admin-ink-soft: #c3b58f;
--admin-muted: #9a8f7e;
--admin-faint: #6e6557;
--admin-line: #3a352e;
--admin-line-soft: #2f2b25;
--admin-accent: #c3a972;
--admin-accent-soft: rgba(195, 169, 114, 0.16);
--admin-accent-line: rgba(195, 169, 114, 0.42);
--admin-on-accent: #1a1815;
--admin-danger: #d68d75;
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
--admin-overlay: rgba(0, 0, 0, 0.6);
}
/* 主题切换时让背景渐变,避免硬切 */
body {
transition: background-color 0.2s ease, color 0.2s ease;
}
a,
.btn,
.tag-chip,
.pill,
.editor-mode button,
.chip,
input.input,
input.select,
textarea.textarea {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
* {
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: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-line);
}
.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: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-line);
}
.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;
}
/* ============================================================
ADMIN — 后台管理界面(侧边栏布局 + 卡片化)
============================================================ */
@@ -1273,14 +775,19 @@ h4 {
.admin-side .group {
display: none;
}
.admin-side .footer {
display: none;
}
.admin-content {
padding: 20px 16px 60px;
}
}
/* footer(含主题切换)到 780px 才隐藏,与全局浮动按钮的出现断点对齐 */
@media (max-width: 780px) {
.admin-side .footer {
display: none;
}
}
/* ---------- card / panel ---------- */
.panel {
+6 -12
View File
@@ -1,16 +1,11 @@
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 }))
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
// 所有 v-html 出口必须经过它。
export function sanitizeHtml(html) {
return DOMPurify.sanitize(html || '')
}
// 编辑器按 `md.render(...)` 的用法调用
export const md = { render: renderMarkdown }
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
@@ -50,9 +45,8 @@ export function relativeDate(iso) {
}
export function stripTags(html) {
const div = document.createElement('div')
div.innerHTML = html || ''
return div.textContent || ''
// 不用 div.innerHTML:那会真正解析并触发 <img onerror> 之类的事件
return new DOMParser().parseFromString(html || '', 'text/html').body.textContent || ''
}
// 短文在列表里没有标题,用正文首句当索引
+7 -1
View File
@@ -37,7 +37,11 @@ const tabs = [
{ label: '短文', value: 'short' }
]
// 快速翻页/切标签时旧响应可能后到并覆盖新结果,用递增序号只采纳最新一次
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
@@ -47,13 +51,15 @@ async function load() {
page: page.value,
size: size.value
})
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
loading.value = false
if (my === seq) loading.value = false
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ 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'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
const route = useRoute()
const post = ref(null)
@@ -65,7 +65,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<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>
<div class="prose" :class="{ 'prose-short': isShort }" v-html="sanitizeHtml(post.content_html)"></div>
<footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags">
+10 -6
View File
@@ -15,28 +15,32 @@ const error = ref('')
const size = 20
const name = computed(() => route.params.slug)
const page = computed(() => Number(route.query.page || 1))
// 快速翻页时旧响应可能后到并覆盖新结果,用递增序号只采纳最新一次
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await publicApi.posts({ tag: name.value, page: 1, size })
const data = await publicApi.posts({ tag: name.value, page: page.value, size })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
} finally {
loading.value = false
if (my === seq) loading.value = false
}
}
onMounted(load)
watch(name, load)
watch([name, page], 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>
-33
View File
@@ -1,33 +0,0 @@
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class', 'html.dark'],
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'var(--border-color)',
accent: 'var(--theme-color)',
hover: 'var(--hover-color)',
},
spacing: {
sidebar: '240px',
},
boxShadow: {
modal: 'rgb(0 0 0 / 20%) 0px 0px 1px, rgb(0 0 0 / 20%) 0px 20px 40px',
'card-hover':
'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 12%) 0px 15px 30px',
card: 'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 4%) 0px 15px 30px',
},
fontFamily: {
mono: 'Roboto Mono, Monaco, monospace',
},
},
},
plugins: [
iconsPlugin({
collections: getIconCollections(['mingcute']),
}),
],
}