[Feature]
All checks were successful
Node.js CI / build-and-test (push) Successful in 25s

移除书签机制
更新了项目描述
增加对有章节作品阅读支持

[BugFix]
BetterHr 无法在 Markdown 显示的问题

[Base]
增加 Markdown 渲染锚点支持
This commit is contained in:
2025-05-18 13:33:39 +08:00
parent 9ef6da3efb
commit 4323acb4d6
13 changed files with 165 additions and 217 deletions

30
package-lock.json generated
View File

@ -13,7 +13,6 @@
"compress-json": "^3.1.1",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"idb": "^8.0.3",
"mdui": "^2.1.3",
"pinia": "^3.0.2",
"vue": "^3.5.13",
@ -23,6 +22,8 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"cross-env": "^7.0.3",
"markdown-it-anchor": "^9.2.0",
"markdown-it-attrs": "^4.3.1",
"sass": "^1.88.0",
"vite": "^6.3.5",
"vite-plugin-md": "^0.21.5",
@ -3797,11 +3798,6 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="
},
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.2.tgz",
@ -4144,6 +4140,28 @@
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/markdown-it-anchor": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz",
"integrity": "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==",
"dev": true,
"peerDependencies": {
"@types/markdown-it": "*",
"markdown-it": "*"
}
},
"node_modules/markdown-it-attrs": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/markdown-it-attrs/-/markdown-it-attrs-4.3.1.tgz",
"integrity": "sha512-/ko6cba+H6gdZ0DOw7BbNMZtfuJTRp9g/IrGIuz8lYc/EfnmWRpaR3CFPnNbVz0LDvF8Gf1hFGPqrQqq7De0rg==",
"dev": true,
"engines": {
"node": ">=6"
},
"peerDependencies": {
"markdown-it": ">= 9.0.0"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-3.0.1.tgz",

View File

@ -16,7 +16,6 @@
"compress-json": "^3.1.1",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"idb": "^8.0.3",
"mdui": "^2.1.3",
"pinia": "^3.0.2",
"vue": "^3.5.13",
@ -26,6 +25,8 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"cross-env": "^7.0.3",
"markdown-it-anchor": "^9.2.0",
"markdown-it-attrs": "^4.3.1",
"sass": "^1.88.0",
"vite": "^6.3.5",
"vite-plugin-md": "^0.21.5",

View File

@ -14,6 +14,7 @@ export function createApp() {
app
.component('ClientOnly', ClientOnly)
.component('Hr', Hr)
.component('BetterHr', Hr)
.component('Form', Form)
return { app, pinia }
}

View File

@ -3,20 +3,40 @@ import { createMemoryHistory, createWebHistory, createRouter } from 'vue-router'
export function createSSRRouter() {
const router = createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else if (to.hash) {
return {
el: to.hash,
behavior: 'smooth',
}
} else {
return { top: 0 }
}
},
routes: [{
path: '/',
name: '前言',
component: () => import('./views/Root.vue'),
meta: {
title: "首页",
title: '首页',
order: 1
},
},{
path: '/work/:id',
name: '阅读',
name: 'work',
component: () => import('./views/Work.vue'),
meta: {
title: "",
title: '阅读',
hidden: true
}
},{
path: '/work/:id/:cid',
name: 'workChapter',
component: () => import('./views/Work.vue'),
meta: {
title: '阅读',
hidden: true
}
},{
@ -24,7 +44,7 @@ export function createSSRRouter() {
name: '关于',
component: () => import('./views/About.vue'),
meta: {
title: "",
title: '',
order: 2
},
},{
@ -32,7 +52,7 @@ export function createSSRRouter() {
name: '开发人员选项',
component: () => import('./views/Developer.vue'),
meta: {
title: "",
title: '',
hidden: true
},
},{
@ -40,7 +60,7 @@ export function createSSRRouter() {
name: 'NotFound',
component: () => import('./views/fallback/NotFound.vue'),
meta: {
title: "页面未找到",
title: '页面未找到',
hidden: true,
code: 404
}

View File

@ -102,8 +102,9 @@ export const useApiStore = defineStore('api', () => {
inited = false
await init()
}
async function getWork(workId) {
return await apiGet('work',{ workId })
async function getWork(workId, chapterId) {
if (chapterId) return await apiGet(`work/${workId}/${chapterId}`)
return await apiGet(`work/${workId}`)
}
return {
init,

View File

@ -1,58 +0,0 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { openDB } from 'idb';
export const useDB = defineStore('_db', () => {
const dbPromise = openDB('data', 1, {
upgrade(db) {
const bookmarkStore = db.createObjectStore('bookmarks', {
keyPath: 'id',
autoIncrement: true,
})
bookmarkStore.createIndex('by-workId', 'workId');
},
})
return {
db: dbPromise
}
})
export const useBookmarkStore = defineStore('bookmark', () => {
const db = useDB().db
async function getAll(workId) {
return (await db).getAllFromIndex('bookmarks', 'by-workId', workId);
}
async function get(id) {
return (await db).get('bookmarks', id);
}
async function add(workId, index, para, name ) {
return (await db).add('bookmarks', {
workId, name, para, index
});
}
async function del(id) {
(await db).delete('bookmarks', id);
}
async function delByWork(workId) {
(await getAll(workId)).forEach(async (item) => {
del(item.id)
})
}
async function updateName(id, name) {
const raw = await get(id)
if (raw) {
raw.name = name
console.log(name)
await (await db).put('bookmarks', raw);
}
}
return {
get,
add,
del,
getAll,
delByWork,
updateName
}
})

View File

@ -8,6 +8,7 @@ import { useApiStore } from '@/stores/api.js'
export const useWorkReadState = defineStore('workRead', () => {
const api = useApiStore()
const id = ref(null)
const cid = ref(null)
const summary = ref(null)
const pesud = ref(null)
const title = ref(null)
@ -25,7 +26,7 @@ export const useWorkReadState = defineStore('workRead', () => {
title.value = data.title
summary.value = [escapeAndFormatText(data.summary)]
pesud.value = data.pesud
text.value = data.text.split('\n\n')
text.value = data.text
publishedTime.value = data.stats.publishedTime
wordCount.value = data.stats.wordCount
kudoCount.value = data.stats.kudoCount
@ -34,10 +35,10 @@ export const useWorkReadState = defineStore('workRead', () => {
fandom.value = data.fandom
lang.value = data.lang
}
async function loadWork(target) {
if (target == id.value || state.value == 'loading') return
async function loadWork(target, targetc) {
if (target == id.value && targetc == cid.value || state.value == 'loading') return
state.value = 'loading'
const result = await api.getWork(target)
const result = await api.getWork(target, targetc)
if (result.status == 200) {
setData(result.data)
state.value = 'ready'
@ -47,7 +48,7 @@ export const useWorkReadState = defineStore('workRead', () => {
}
}
return {
id,
id, cid,
title,
summary,
pesud,

View File

@ -18,6 +18,13 @@
- Vue 3 [vuejs.org](https://vuejs.org)
- Vite 6 [vitejs.dev](https://vite.dev)
废弃特性
---
### 书签 {#deprecated-feature-bookmark}
因为底层 IndexedDB 更新困难和作品段落解析困难问题, 所以在 **v1.0.7** 以后的版本废弃了书签机制
其他
---
本站支持 "Server Side Rendering" by Vite SSR

View File

@ -14,17 +14,24 @@
现在这个站点还处于测试阶段, 只有一种使用方法 *(后面会扩展)*
首先你需要一个 AO3 链接
首先你需要一个 AO3 链接 (或者数字 ID)
比如:
https://archiveofourown.org/works/114514
带章节的话就是
https://archiveofourown.org/works/114514/chapters/1919810
接着将前面的部分替换为本站点的链接 (保留数字ID部分):
即:
https://ao3.unknownmp.top/work/114514
这里的数字ID即为`114514`
章节:
https://ao3.unknownmp.top/work/114514/1919810
浏览器打开它, OK 你会用了🤓👆!
@ -33,7 +40,7 @@
## 功能与特性 🤗
- ✅ 预览
-书签 (本地)
- 📝 历史记录 (本地)
-作品详细数据
- 📝 搜索
- ❌ 不再支持! [详情](/about#deprecated-feature-bookmark) 书签 (本地)

View File

@ -5,7 +5,6 @@ defineProps(['class']) // 接收外部 class 属性
<template>
<ClientOnly>
<!-- class 传入 mdui-divider -->
<mdui-divider :class="['hr-divider', $attrs.class]"></mdui-divider>
<template #ssr><hr :class="['hr-divider', $attrs.class]" /></template>
</ClientOnly>

View File

@ -20,6 +20,7 @@ function convert(from) {
}
} else if (from.includes('https://archiveofourown.org/works/')) {
const sid = from.split('https://archiveofourown.org/works/')[1];
console.log(sid)
if ( sid ) {
const id = Number(sid)
if (id) {
@ -27,17 +28,24 @@ function convert(from) {
type: 's',
id
}
} else {
const splited = sid.split('/chapters/')
const id = Number(splited[0])
const cid = Number(splited[1])
if (id && cid) {
return {
type: 'c',
id, cid
}
}
}
}
}
return {
type: null,
}
return { type: null }
}
function onConvert(data) {
const { type, id, cid } = convert(data.src)
console.log(type, id, cid)
if (type == null) {
err.value = true
srcText.value?.focus()

View File

@ -10,11 +10,8 @@ const workReadState = useWorkReadState()
import { useRouteStore } from '@/stores/route.js'
const routeState = useRouteStore()
import { useBookmarkStore } from '../stores/db.js'
import 'mdui/components/list.js'
import 'mdui/components/list-item.js'
import 'mdui/components/dialog.js'
import 'mdui/components/divider.js'
import 'mdui/components/linear-progress.js'
import 'mdui/components/fab.js'
@ -37,16 +34,11 @@ import { mduiSnackbar } from '../utils.js'
const fabExtended = ref(false)
const content = ref(null)
const readPercent = ref(0)
const bookmarkDialog = ref(null)
const bookmarks = ref([])
const bookmarkMenu = ref(false)
const bookmarkSelect = ref(null)
let readIndex = 0
let lastPercent = 0
let lastCloseTimer = null
let isObserver = null
let bookmarkStore = null
let paragraphs = []
let currentParagraph = null
@ -56,100 +48,22 @@ const categoryName = {
fm: '女/男'
}
async function addBookmark() {
if (currentParagraph) {
const id = await bookmarkStore.add(workReadState.id, readIndex, currentParagraph.textContent.slice(0,20), '')
bookmarks.value.push(await bookmarkStore.get(id))
snackbar({
message: `在第 ${readIndex} 段 (${readPercent.value}%) 处新建了一个书签`,
action: "编辑",
onActionClick: () => {
prompt({
headline: "修改书签",
description: "新名字:",
confirmText: "完成",
cancelText: "算了",
onConfirm: (value) => {
bookmarkStore.updateName(id, value)
bookmarks.value[bookmarks.value.length - 1].name = value
}
});
}})
}
}
async function jumpTo(index) {
const value = bookmarks.value[index].index
const target = paragraphs[value]
bookmarkDialog.value.open = false
await nextTick()
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest'
})
}
}
async function delAllBookmark() {
confirm({
headline: '警告',
description: '这会清空所有书签! 不可恢复!',
confirmText: '我明白',
cancelText: '算了',
closeOnOverlayClick: true,
closeOnEsc: true,
onConfirm: () => {
bookmarkStore.delByWork(workReadState.id)
bookmarks.value = []
mduiSnackbar('书签清空辣!')
},
})
}
async function editBookmark() {
prompt({
headline: "修改书签",
description: "新名字:",
confirmText: "完成",
cancelText: "算了",
onConfirm: (value) => {
bookmarkStore.updateName(bookmarkSelect.value.bk.id, value)
bookmarks.value[bookmarkSelect.value.index].name = value
}
});
}
function openBookmarkMenu(bk, index) {
bookmarkSelect.value = { bk, index };
bookmarkMenu.value.open = true
}
async function deleteBookmark() {
if (bookmarkSelect.value) {
bookmarkStore.del(bookmarkSelect.value.bk.id)
bookmarks.value.splice(bookmarkSelect.value.index,1)
bookmarkSelect.value = null
}
}
onServerPrefetch(async () => {
await workReadState.loadWork(route.params.id)
await workReadState.loadWork(route.params.id, route.params.cid)
})
onMounted(async () => {
bookmarkStore = useBookmarkStore()
if (workReadState.state != 'ssrnotfound') await workReadState.loadWork(route.params.id)
if (workReadState.state != 'ssrnotfound') await workReadState.loadWork(route.params.id, route.params.cid)
if (workReadState.state == 'ready') {
routeState.customTitle = workReadState.title
const paraCount = workReadState.text.length - 2
const paraCount = workReadState.text.length - 1
isObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
currentParagraph = entry.target
readIndex = entry.target.dataset.index;
readPercent.value = parseInt(readIndex / paraCount * 100)
readPercent.value = parseInt(readIndex / paraCount * 100)
if (lastPercent == 0) {
lastPercent = readPercent.value
return
@ -171,7 +85,6 @@ onMounted(async () => {
await nextTick()
paragraphs = content.value?.querySelectorAll('p');
paragraphs?.forEach(p => isObserver.observe(p));
bookmarks.value = await bookmarkStore.getAll(workReadState.id)
}
})
@ -189,7 +102,10 @@ onBeforeUnmount(() => {
<template v-if="workReadState.state == 'notfound' || workReadState.state == 'ssrnotfound'">
<h2>文章不存在...</h2>
是不是链接没有复制完全?<br/>
ID: {{workReadState.id}}<br/>
ID: {{ workReadState.id }}<br/>
<template v-if="workReadState.cid">
CID: {{ workReadState.cid }}
</template>
<a @click="$router.back()">返回</a>
</template>
<template v-if="workReadState.state == 'ready'">
@ -200,14 +116,14 @@ onBeforeUnmount(() => {
<mdui-collapse-item value="info"><mdui-list-item class="infoblockhead" slot="header">
作品信息
</mdui-list-item><div class="infoblock"><dl>
<dt>分类</dt><ul>
<template v-if="workReadState.category"><dt>分类</dt><ul>
<li v-for="item in workReadState.category" :key="item">
{{ categoryName[item] }}</li>
</ul>
<dt>原著</dt><ul>
</ul></template>
<template v-if="workReadState.fandom"><dt>作品圈</dt><ul>
<li v-for="item in workReadState.fandom" :key="item">
{{ item }}</li>
</ul>
</ul></template>
<dt>语言</dt><dd>
{{ workReadState.lang }}
</dd>
@ -236,57 +152,33 @@ onBeforeUnmount(() => {
<p v-for="(para, index) in workReadState.text" :key="para" :data-index="index">{{ para }}</p>
</div>
</article>
<mdui-fab class="mdui-fab" :extended="fabExtended" @click="bookmarkDialog.open = true">
<mdui-fab class="mdui-fab" :extended="fabExtended">
<mdui-icon-bookmark slot="icon"></mdui-icon-bookmark>
{{ readPercent }}%
</mdui-fab>
<mdui-dialog ref='bookmarkDialog' close-on-overlay-click>
<span slot="headline">书签</span>
<span slot="description">
{{ bookmarks.length }}
<br/>
点击跳转, 长按条目以 更新/删除
</span>
<mdui-list v-if="bookmarks.length" style="max-width: 50vh; max-height: 90vh;">
<mdui-list-item
v-for="(bk, index) in bookmarks"
@click="jumpTo(index)"
@contextmenu.prevent="openBookmarkMenu(bk, index)"
>
{{ bk.name || bk.para }}
</mdui-list-item>
</mdui-list>
<span v-else>还没有书签</span>
<mdui-dropdown ref='bookmarkMenu' trigger="manual" open-on-pointer>
<span slot="trigger" />
<mdui-menu>
<mdui-menu-item @click="deleteBookmark()">删除</mdui-menu-item>
<mdui-menu-item @click="editBookmark()">编辑</mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
<mdui-button slot="action" @click="delAllBookmark" variant="filled">清空</mdui-button>
<mdui-button slot="action" @click="addBookmark" variant="text">新建</mdui-button>
</mdui-dialog>
</template>
<template #ssr>
<template v-if="workReadState.state == 'notfound' || workReadState.state == 'ssrnotfound'">
<h2>文章不存在...</h2>
是不是链接没有复制完全?<br/>
ID: {{workReadState.id}}<br/>
<template v-if="workReadState.cid">
CID: {{ workReadState.cid }}
</template>
<a @click="$router.back()">返回</a>
</template>
<template v-if="workReadState.state == 'ready'">
<h1>{{ workReadState.title }}</h1>
<h2>{{ workReadState.pesud }}</h2>
<dl>
<dt>分类</dt><ul>
<template v-if="workReadState.category"><dt>作品圈</dt><ul>
<li v-for="item in workReadState.category" :key="item">
{{ categoryName[item] }}</li>
</ul>
<dt>原著</dt><ul>
</ul></template>
<template v-if="workReadState.fandom"><dt>原著</dt><ul>
<li v-for="item in workReadState.fandom" :key="item">
{{ item }}</li>
</ul>
</ul></template>
<dt>语言</dt><dd>
{{ workReadState.lang }}
</dd>

View File

@ -5,6 +5,8 @@ import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import markdown from 'vite-plugin-md'
import markdownItAnchor from 'markdown-it-anchor'
import markdownItAttrs from 'markdown-it-attrs'
// https://vite.dev/config/
export default defineConfig({
@ -20,8 +22,57 @@ export default defineConfig({
vueJsx(),
vueDevTools(),
markdown({
markdownItSetup(md) {
md.renderer.rules.hr = () => "<Hr />"
markdownItSetup(mdit) {
mdit.use(markdownItAttrs)
mdit.use(markdownItAnchor, {
permalink: markdownItAnchor.permalink.ariaHidden({
placement: 'before',
symbol: '#',
level: [1, 2, 3, 4],
}),
slugify: s => s
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "") // 去除重音符号
.replace(/[^\w\s-]/g, "") // 移除 emoji 和特殊符号
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
})
mdit.renderer.rules.hr = () => {
console.log('Custom <hr> rendered 🚀');
return '<div><BetterHr /></div>'
}
const defaultOpen = mdit.renderer.rules.link_open || ((tokens, idx, options, env, self) => {
return self.renderToken(tokens, idx, options)
})
const defaultClose = mdit.renderer.rules.link_close || ((tokens, idx, options, env, self) => {
return self.renderToken(tokens, idx, options)
})
mdit.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
const href = token.attrGet('href') || ''
const isExternal = /^https?:\/\//.test(href)
const isInternal = /^\/(?!\/)/.test(href)
if (isInternal) {
// 转换为 <router-link> 并设置 `to`
token.tag = 'router-link'
token.attrSet('to', href)
token.attrs = token.attrs?.filter(attr => attr[0] !== 'href') || []
} else if (isExternal) {
// 站外链接加上 target="_blank" rel="noopener noreferrer"
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
}
return defaultOpen(tokens, idx, options, env, self)
}
mdit.renderer.rules.link_close = (tokens, idx, options, env, self) => {
const previous = tokens[idx - 1]
if (previous?.tag === 'router-link') {
tokens[idx].tag = 'router-link'
}
return defaultClose(tokens, idx, options, env, self)
}
}
})
],