- 阅读页新增阅读/历史模式切换 - 历史列表展示修订号、作者、摘要、时间与小修改标记 - 支持查看任意历史修订的渲染内容 - 与当前版本的逐行差异对比,新增/删除行高亮 - 支持撤销到指定修订,带二次确认 - 修复导航栈与修订列表变量重名 - 补充维基历史相关 API:历史列表、修订详情、差异对比、撤销 - 中英文各补 13 个文案
This commit is contained in:
1 parent
aaf394f790
commit
a325298eb6
4 files changed
+352
-9
No files matched your search
@@ -6,12 +6,19 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
type SkinSiteWikiPageDetail,
|
||||
skinSiteWikiPage,
|
||||
skinSiteWikiDiff,
|
||||
skinSiteWikiHistory,
|
||||
skinSiteWikiRevert,
|
||||
skinSiteWikiRevision,
|
||||
type SkinSiteWikiDiffLine,
|
||||
type SkinSiteWikiNode,
|
||||
type SkinSiteWikiRevisionInfo,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
|
||||
initialTitle: '',
|
||||
@@ -40,13 +47,50 @@ const messages = defineMessages({
|
||||
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
|
||||
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
|
||||
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
|
||||
history: { id: 'app.skin-site.wiki.history', defaultMessage: 'History' },
|
||||
read: { id: 'app.skin-site.wiki.read', defaultMessage: 'Read' },
|
||||
historyEmpty: {
|
||||
id: 'app.skin-site.wiki.history-empty',
|
||||
defaultMessage: 'No revisions yet.',
|
||||
},
|
||||
noSummary: {
|
||||
id: 'app.skin-site.wiki.no-summary',
|
||||
defaultMessage: 'No edit summary',
|
||||
},
|
||||
minor: { id: 'app.skin-site.wiki.minor', defaultMessage: 'Minor' },
|
||||
viewRevision: { id: 'app.skin-site.wiki.view-revision', defaultMessage: 'View' },
|
||||
revertTo: {
|
||||
id: 'app.skin-site.wiki.revert-to',
|
||||
defaultMessage: 'Revert to this',
|
||||
},
|
||||
confirmRevert: {
|
||||
id: 'app.skin-site.wiki.confirm-revert',
|
||||
defaultMessage: 'Confirm revert',
|
||||
},
|
||||
revertDone: {
|
||||
id: 'app.skin-site.wiki.revert-done',
|
||||
defaultMessage: 'Page reverted to the selected revision.',
|
||||
},
|
||||
revisionPreview: {
|
||||
id: 'app.skin-site.wiki.revision-preview',
|
||||
defaultMessage: 'Preview of revision #{id}',
|
||||
},
|
||||
closePreview: {
|
||||
id: 'app.skin-site.wiki.close-preview',
|
||||
defaultMessage: 'Close preview',
|
||||
},
|
||||
diffTitle: {
|
||||
id: 'app.skin-site.wiki.diff-title',
|
||||
defaultMessage: 'Line diff against the current revision',
|
||||
},
|
||||
cancel: { id: 'app.skin-site.wiki.cancel', defaultMessage: 'Cancel' },
|
||||
})
|
||||
|
||||
const titleInput = ref(props.initialTitle)
|
||||
const page = ref<SkinSiteWikiPageDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const notFound = ref(false)
|
||||
const history = ref<string[]>([])
|
||||
const navStack = ref<string[]>([])
|
||||
|
||||
const currentTitle = computed(() => page.value?.title ?? '')
|
||||
|
||||
@@ -64,8 +108,8 @@ async function openPage(title: string) {
|
||||
return
|
||||
}
|
||||
page.value = res.payload
|
||||
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
|
||||
history.value.push(currentTitle.value)
|
||||
if (currentTitle.value && navStack.value[navStack.value.length - 1] !== currentTitle.value) {
|
||||
navStack.value.push(currentTitle.value)
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
@@ -77,7 +121,7 @@ async function openPage(title: string) {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const prev = history.value.pop()
|
||||
const prev = navStack.value.pop()
|
||||
if (prev) void openPage(prev)
|
||||
}
|
||||
|
||||
@@ -89,6 +133,77 @@ function formatDate(ts: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
const mode = ref<'read' | 'history'>('read')
|
||||
const history = ref<SkinSiteWikiRevisionInfo[]>([])
|
||||
const historyLoading = ref(false)
|
||||
|
||||
const selectedRevisionId = ref<number | null>(null)
|
||||
const selectedRevisionNodes = ref<SkinSiteWikiNode[] | null>(null)
|
||||
const diffLines = ref<SkinSiteWikiDiffLine[]>([])
|
||||
const pendingRevertId = ref<number | null>(null)
|
||||
const reverting = ref(false)
|
||||
|
||||
async function showHistory() {
|
||||
if (!page.value) return
|
||||
mode.value = 'history'
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res = await skinSiteWikiHistory(page.value.title)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
history.value = res.payload.items ?? []
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToRead() {
|
||||
mode.value = 'read'
|
||||
selectedRevisionNodes.value = null
|
||||
diffLines.value = []
|
||||
pendingRevertId.value = null
|
||||
}
|
||||
|
||||
async function viewRevision(revisionId: number) {
|
||||
if (!page.value) return
|
||||
try {
|
||||
const res = await skinSiteWikiRevision(page.value.title, revisionId)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
selectedRevisionId.value = revisionId
|
||||
selectedRevisionNodes.value = res.payload.nodes ?? []
|
||||
diffLines.value = []
|
||||
if (revisionId !== page.value.revisionId) {
|
||||
const diff = await skinSiteWikiDiff(page.value.title, revisionId, page.value.revisionId)
|
||||
if (diff.success) diffLines.value = diff.payload ?? []
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function revertToRevision(revisionId: number) {
|
||||
if (!page.value || reverting.value) return
|
||||
reverting.value = true
|
||||
try {
|
||||
const res = await skinSiteWikiRevert({
|
||||
title: page.value.title,
|
||||
revisionId,
|
||||
baseRevisionId: page.value.revisionId,
|
||||
})
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
page.value = res.payload
|
||||
pendingRevertId.value = null
|
||||
backToRead()
|
||||
addNotification({ type: 'success', title: formatMessage(messages.revertDone) })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
reverting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigate(title: string) {
|
||||
if (title) void openPage(title)
|
||||
}
|
||||
@@ -102,7 +217,7 @@ onMounted(() => {
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="history.length"
|
||||
v-if="navStack.length"
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="goBack"
|
||||
@@ -124,6 +239,24 @@ onMounted(() => {
|
||||
>
|
||||
{{ formatMessage(messages.open) }}
|
||||
</button>
|
||||
<template v-if="page">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
|
||||
:class="mode === 'read' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
|
||||
@click="backToRead"
|
||||
>
|
||||
{{ formatMessage(messages.read) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
|
||||
:class="mode === 'history' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
|
||||
@click="showHistory"
|
||||
>
|
||||
{{ formatMessage(messages.history) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
|
||||
@@ -142,7 +275,7 @@ onMounted(() => {
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<template v-else-if="mode === 'read'">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
|
||||
<span class="text-[11px] text-secondary">
|
||||
@@ -188,5 +321,123 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="historyLoading" class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="selectedRevisionNodes"
|
||||
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.revisionPreview, { id: selectedRevisionId }) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
|
||||
@click="selectedRevisionNodes = null; diffLines = []"
|
||||
>
|
||||
{{ formatMessage(messages.closePreview) }}
|
||||
</button>
|
||||
</div>
|
||||
<article class="min-w-0 text-sm leading-7 text-contrast">
|
||||
<WikiNodeRenderer :nodes="selectedRevisionNodes" @navigate="onNavigate" />
|
||||
</article>
|
||||
<details v-if="diffLines.length" class="rounded-lg border border-solid border-surface-5 p-3">
|
||||
<summary class="cursor-pointer text-xs font-semibold text-contrast">
|
||||
{{ formatMessage(messages.diffTitle) }}
|
||||
</summary>
|
||||
<div class="mt-2 max-h-96 overflow-auto font-mono text-[11px]">
|
||||
<div
|
||||
v-for="(line, idx) in diffLines"
|
||||
:key="idx"
|
||||
class="px-2 py-0.5 whitespace-pre-wrap"
|
||||
:class="
|
||||
line.type === 'ADDED'
|
||||
? 'bg-green/15 text-green'
|
||||
: line.type === 'REMOVED'
|
||||
? 'bg-red/15 text-red'
|
||||
: 'text-secondary'
|
||||
"
|
||||
>
|
||||
{{ line.type === 'ADDED' ? '+' : line.type === 'REMOVED' ? '-' : ' ' }}{{ line.text }}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!history.length"
|
||||
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.historyEmpty) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col divide-y divide-surface-5">
|
||||
<div
|
||||
v-for="revision in history"
|
||||
:key="revision.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">{{ revision.authorName }}</span>
|
||||
<span
|
||||
v-if="revision.minor"
|
||||
class="rounded bg-button-bg px-2 py-0.5 text-[10px] text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.minor) }}
|
||||
</span>
|
||||
<span class="text-[10px] text-secondary">#{{ revision.id }}</span>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-xs text-secondary">
|
||||
{{ revision.summary || formatMessage(messages.noSummary) }}
|
||||
</p>
|
||||
<p class="m-0 mt-0.5 text-[10px] text-secondary">{{ formatDate(revision.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="viewRevision(revision.id)"
|
||||
>
|
||||
{{ formatMessage(messages.viewRevision) }}
|
||||
</button>
|
||||
<template v-if="page && revision.id !== page.revisionId">
|
||||
<button
|
||||
v-if="pendingRevertId !== revision.id"
|
||||
type="button"
|
||||
class="rounded-lg bg-yellow/20 px-3 py-1 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="reverting"
|
||||
@click="pendingRevertId = revision.id"
|
||||
>
|
||||
{{ formatMessage(messages.revertTo) }}
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="reverting"
|
||||
@click="revertToRevision(revision.id)"
|
||||
>
|
||||
{{ formatMessage(messages.confirmRevert) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
|
||||
@click="pendingRevertId = null"
|
||||
>
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2539,3 +2539,69 @@ export function skinSiteWikiAttachment(
|
||||
export function skinSiteWikiAttachmentUrl(id: number): string {
|
||||
return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
|
||||
}
|
||||
|
||||
export interface SkinSiteWikiRevisionInfo {
|
||||
id: number
|
||||
authorName: string
|
||||
summary: string
|
||||
minor: boolean
|
||||
createdAt: number
|
||||
sourceWiki?: string | null
|
||||
sourceRevisionId?: number | null
|
||||
}
|
||||
|
||||
export interface SkinSiteWikiRevisionPage {
|
||||
items: SkinSiteWikiRevisionInfo[]
|
||||
nextCursor?: number | null
|
||||
}
|
||||
|
||||
export interface SkinSiteWikiRevisionDetail {
|
||||
revision: SkinSiteWikiRevisionInfo
|
||||
source: string
|
||||
nodes: SkinSiteWikiNode[]
|
||||
}
|
||||
|
||||
export interface SkinSiteWikiDiffLine {
|
||||
type: 'UNCHANGED' | 'REMOVED' | 'ADDED'
|
||||
oldLine?: number | null
|
||||
newLine?: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export function skinSiteWikiHistory(
|
||||
title: string,
|
||||
before?: number,
|
||||
): Promise<SkinSiteResponse<SkinSiteWikiRevisionPage>> {
|
||||
const query = new URLSearchParams({ title })
|
||||
if (before) query.set('before', String(before))
|
||||
return skinSiteRequest<SkinSiteWikiRevisionPage>('wiki/history?' + query.toString())
|
||||
}
|
||||
|
||||
export function skinSiteWikiRevision(
|
||||
title: string,
|
||||
revisionId: number,
|
||||
): Promise<SkinSiteResponse<SkinSiteWikiRevisionDetail>> {
|
||||
return skinSiteRequest<SkinSiteWikiRevisionDetail>(
|
||||
'wiki/revision/' + revisionId + '?title=' + encodeURIComponent(title),
|
||||
)
|
||||
}
|
||||
|
||||
export function skinSiteWikiDiff(
|
||||
title: string,
|
||||
older: number,
|
||||
newer: number,
|
||||
): Promise<SkinSiteResponse<SkinSiteWikiDiffLine[]>> {
|
||||
const query = new URLSearchParams({ title, older: String(older), newer: String(newer) })
|
||||
return skinSiteRequest<SkinSiteWikiDiffLine[]>('wiki/diff?' + query.toString())
|
||||
}
|
||||
|
||||
export function skinSiteWikiRevert(data: {
|
||||
title: string
|
||||
revisionId: number
|
||||
baseRevisionId: number
|
||||
}): Promise<SkinSiteResponse<SkinSiteWikiPageDetail>> {
|
||||
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/revert', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
@@ -12434,5 +12434,18 @@
|
||||
"app.skin-site.wiki.updated-at": {"message": "Last edited {date} by {author}"},
|
||||
"app.skin-site.wiki.revision": {"message": "Revision #{id}"},
|
||||
"app.skin-site.wiki.categories": {"message": "Categories"},
|
||||
"app.skin-site.wiki.back": {"message": "Back"}
|
||||
"app.skin-site.wiki.back": {"message": "Back"},
|
||||
"app.skin-site.wiki.history": {"message": "History"},
|
||||
"app.skin-site.wiki.read": {"message": "Read"},
|
||||
"app.skin-site.wiki.history-empty": {"message": "No revisions yet."},
|
||||
"app.skin-site.wiki.no-summary": {"message": "No edit summary"},
|
||||
"app.skin-site.wiki.minor": {"message": "Minor"},
|
||||
"app.skin-site.wiki.view-revision": {"message": "View"},
|
||||
"app.skin-site.wiki.revert-to": {"message": "Revert to this"},
|
||||
"app.skin-site.wiki.confirm-revert": {"message": "Confirm revert"},
|
||||
"app.skin-site.wiki.revert-done": {"message": "Page reverted to the selected revision."},
|
||||
"app.skin-site.wiki.revision-preview": {"message": "Preview of revision #{id}"},
|
||||
"app.skin-site.wiki.close-preview": {"message": "Close preview"},
|
||||
"app.skin-site.wiki.diff-title": {"message": "Line diff against the current revision"},
|
||||
"app.skin-site.wiki.cancel": {"message": "Cancel"}
|
||||
}
|
||||
@@ -12265,5 +12265,18 @@
|
||||
"app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"},
|
||||
"app.skin-site.wiki.revision": {"message": "修订 #{id}"},
|
||||
"app.skin-site.wiki.categories": {"message": "分类"},
|
||||
"app.skin-site.wiki.back": {"message": "返回"}
|
||||
"app.skin-site.wiki.back": {"message": "返回"},
|
||||
"app.skin-site.wiki.history": {"message": "历史"},
|
||||
"app.skin-site.wiki.read": {"message": "阅读"},
|
||||
"app.skin-site.wiki.history-empty": {"message": "暂无修订记录。"},
|
||||
"app.skin-site.wiki.no-summary": {"message": "未填写编辑摘要"},
|
||||
"app.skin-site.wiki.minor": {"message": "小修改"},
|
||||
"app.skin-site.wiki.view-revision": {"message": "查看"},
|
||||
"app.skin-site.wiki.revert-to": {"message": "撤销至此版本"},
|
||||
"app.skin-site.wiki.confirm-revert": {"message": "确认撤销"},
|
||||
"app.skin-site.wiki.revert-done": {"message": "页面已撤销到指定修订。"},
|
||||
"app.skin-site.wiki.revision-preview": {"message": "修订 #{id} 预览"},
|
||||
"app.skin-site.wiki.close-preview": {"message": "关闭预览"},
|
||||
"app.skin-site.wiki.diff-title": {"message": "与当前版本的逐行差异"},
|
||||
"app.skin-site.wiki.cancel": {"message": "取消"}
|
||||
}
|
||||
Reference in new issue
Block a user