perf: 优化文件历史记录展示

This commit is contained in:
jmal 2025-06-17 15:54:39 +08:00
parent 388cf75633
commit 34a21327a2
6 changed files with 432 additions and 195 deletions

View File

@ -471,4 +471,13 @@ export default {
data
})
},
// 获取office文件历史
getOfficeHistoryList: function(params) {
return request({
url: '/history/office/list',
method: 'get',
params,
})
},
}

View File

@ -1,223 +1,468 @@
<template>
<div>
<el-popover
v-if="hasHistoryVersion"
style="margin-right: 15px"
:visible-arrow="false"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' history-popover'"
placement="bottom"
@show="popoverShow"
@hide="popoverHide"
v-model="historyListPopoverVisible"
trigger="manual">
<el-table :data="fileHistoryList" height="300" row-class-name="file-history-row" :class="lightTheme? 'file-history-list' : 'dark-file-history-list'">
<el-table-column show-overflow-tooltip width="200" property="metadata.filename" label="文件名"></el-table-column>
<el-table-column width="190" property="metadata.time" label="版本日期"></el-table-column>
<el-table-column label="操作" width="210">
<template slot-scope="scope">
<div style="display: flex">
<el-button round
size="mini"
:class="lightTheme?'':'dark-button'"
@click="viewHistoryFile(scope.row, true, false)" :loading="historyOperationLoading">预览</el-button>
<el-popover
placement="bottom"
width="310"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' delete-' + scope.row.id"
style="margin: 0 10px;"
:visible-arrow="false"
trigger="click">
<p class="el-popconfirm__main">
<i class="el-popconfirm__icon el-icon-question" style="color: rgb(255, 153, 0);"></i>
{{'确定要恢复到'+ scope.row.metadata.time +'吗?当前内容将被覆盖!'}}
</p>
<div style="text-align: right; margin: 0">
<el-button round size="mini" type="text" @click="cancelRecovery(scope.row.id)">取消</el-button>
<el-button round type="primary" size="mini" @click="confirmRecoveryHistoryFile(scope.row)">确定</el-button>
</div>
<el-button round slot="reference" size="mini" :id="'recoveryVersionBtn-'+scope.row.id" type="warning" :loading="historyOperationLoading">恢复</el-button>
</el-popover>
<el-popover
placement="bottom"
width="310"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' delete-' + scope.row.id"
:visible-arrow="false"
trigger="click">
<p class="el-popconfirm__main">
<i class="el-popconfirm__icon el-icon-question" style="color: rgb(255, 153, 0);"></i>
{{'确定要删除'+ scope.row.metadata.time +'吗?这将永久删除该历史版本!'}}
</p>
<div style="text-align: right; margin: 0">
<el-button round size="mini" type="text" @click="cancelDelete(scope.row.id)">取消</el-button>
<el-button round type="primary" size="mini" @click="confirmDeleteHistoryFile(scope.row.id)">确定</el-button>
</div>
<el-button round slot="reference" size="mini" :id="'deleteVersionBtn-'+scope.row.id" type="danger" :loading="historyOperationLoading">删除</el-button>
</el-popover>
<el-popover
ref="historyPopover"
:value="historyListPopoverVisible"
style="margin-right: 15px"
:visible-arrow="false"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' version-timeline-popover'"
placement="bottom"
width="400"
trigger="manual"
@show="popoverShow"
@hide="popoverHide"
>
<div :class="lightTheme ? 'version-history-timeline' : 'dark-version-history-timeline'">
<div class="scrollbar-wrapper" @scroll="handleScroll">
<div class="timeline-container-scrollbar">
<div v-if="loading && fileHistoryList.length === 0" class="timeline-loading">加载中...</div>
<div v-if="!loading && fileHistoryList.length === 0 && finished" class="timeline-empty">暂无历史版本</div>
<div v-for="(item, index) in fileHistoryList" :key="item.id" class="timeline-item">
<div class="timeline-icon">
<el-avatar :src="imageUrl + item.metadata.operator" size="medium" icon="el-icon-user-solid"></el-avatar>
</div>
</template>
</el-table-column>
</el-table>
<div class="timeline-line" v-if="index !== fileHistoryList.length - 1"></div>
<div class="timeline-content">
<div class="timeline-header">
<span class="timeline-date">{{ item.metadata.time }}</span>
<span class="version-filename" :title="item.metadata.filename">{{ item.metadata.filename }}</span>
</div>
<div class="timeline-second-line">
<div><span class="timeline-operator">{{ item.metadata.operator }}</span></div>
<div class="timeline-actions">
<el-button round size="mini" :class="lightTheme ? '' : 'dark-button'"
@click="viewHistoryFile(item, true, false)" :loading="historyOperationLoading">预览
</el-button>
<el-button round slot="reference" :style="versionBtnStyle" ref="versionBtn" :size="buttonSize" :class="lightTheme?' history-version-btn':'dark-button history-version-btn'" @click="showOrHidePopover">历史版本</el-button>
</el-popover>
</div>
<el-popover placement="bottom" width="310"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' recovery-' + item.id"
:visible-arrow="false" trigger="click">
<p class="el-popconfirm__main"><i class="el-popconfirm__icon el-icon-question"
style="color: rgb(255, 153, 0);"></i>{{ '确定要恢复到' + item.metadata.time + '吗?当前内容将被覆盖!'
}}
</p>
<div style="text-align: right; margin: 0">
<el-button round size="mini" type="text" @click="cancelRecovery(item.id)">取消</el-button>
<el-button round type="primary" size="mini" @click="confirmRecoveryHistoryFile(item)">确定
</el-button>
</div>
<el-button round slot="reference" size="mini" :id="'recoveryVersionBtn-'+item.id" type="warning"
:loading="historyOperationLoading">恢复
</el-button>
</el-popover>
<el-popover placement="bottom" width="310"
:popper-class="(lightTheme ? 'file-history-popover' : 'dark-file-history-popover') + ' delete-' + item.id"
:visible-arrow="false" trigger="click">
<p class="el-popconfirm__main"><i class="el-popconfirm__icon el-icon-question"
style="color: rgb(255, 153, 0);"></i>{{ '确定要删除' + item.metadata.time + '吗?这将永久删除该历史版本!'
}}
</p>
<div style="text-align: right; margin: 0">
<el-button round size="mini" type="text" @click="cancelDelete(item.id)">取消</el-button>
<el-button round type="primary" size="mini" @click="confirmDeleteHistoryFile(item.id)">确定
</el-button>
</div>
<el-button round slot="reference" size="mini" :id="'deleteVersionBtn-'+item.id" type="danger"
:loading="historyOperationLoading">删除
</el-button>
</el-popover>
</div>
</div>
</div>
</div>
<div v-if="loading && fileHistoryList.length > 0" class="timeline-loading-more">加载中...</div>
</div>
</div>
</div>
<el-button
slot="reference"
round
:style="versionBtnStyle"
:size="buttonSize"
:class="lightTheme ? ' history-version-btn' : 'dark-button history-version-btn'"
@click="showOrHidePopover"
>
历史版本
</el-button>
</el-popover>
</template>
<script>
import historyApi from "@/api/file-history"
import historyApi from '@/api/file-history'
export default {
name: "history-popover",
name: 'history-popover',
props: {
hasHistoryVersion: {
type: Object,
default: function () {
return {metadata: {}}
}
type: Object, // 注意:这里是 Object,虽然通常用 Boolean,但为保持兼容性,维持原样
default: function() {
return { metadata: {} }
},
},
lightTheme: {
type: Boolean,
default: true
},
historyListPopoverVisible: {
type: Boolean,
default: false
},
historyOperationLoading: {
type: Boolean,
default: false
},
saved: {
type: Boolean,
default: true
},
buttonSize: {
type: String,
default: 'mini'
},
transparent: {
type: Boolean,
default: false
lightTheme: { type: Boolean, default: true },
historyListPopoverVisible: { type: Boolean, default: false },
historyOperationLoading: { type: Boolean, default: false },
saved: { type: Boolean, default: true },
buttonSize: { type: String, default: 'mini' },
transparent: { type: Boolean, default: false },
},
data() {
return {
imageUrl: `${process.env.VUE_APP_BASE_API}/view/thumbnail/user/`,
fileHistoryList: [],
loading: false,
finished: false,
historyPageIndex: 1,
historyPageSize: 20,
loadApiFunction: null,
loadApiParams: {},
}
},
computed: {
versionBtnStyle() {
if (this.transparent) {
return 'background: #00000000;border: 0;'
} else {
return ''
}
}
},
data() {
return {
fileHistoryList: [],
recoveryHistoryListenerMap: new Map(),
deleteHistoryListenerMap: new Map(),
historyPageIndex: 1,
historyPageSize: 50
}
return ''
},
},
watch: {
historyListPopoverVisible(value) {
if (value) {
document.getElementById('app').addEventListener('click', this.onGlobalClick)
} else {
// destroy elementListeners
document.getElementById('app').removeEventListener('click', this.onGlobalClick)
}
},
},
methods: {
/**
* @public
*/
loadHistoryList(id) {
if (!this.$store.state.user.token || !this.$store.state.user.name) {
return
if (!this.$store.state.user.token || !this.$store.state.user.name) return
this.resetState()
this.loadApiFunction = historyApi.fileHistoryList
this.loadApiParams = { fileId: id, username: this.$store.state.user.name }
this._internalLoadHistory()
},
/**
* @public
*/
loadHistoryPathList(pathname) {
if (!this.$store.getters.token) return
this.resetState()
this.loadApiFunction = historyApi.fileHistoryPathList
this.loadApiParams = { path: encodeURI(pathname), username: this.$store.state.user.name }
this._internalLoadHistory()
},
// --- 内部方法 ---
_internalLoadHistory(isLoadMore = false) {
if (this.loading || (isLoadMore && this.finished)) return
if (!isLoadMore) {
this.historyPageIndex = 1
this.fileHistoryList = []
} else {
this.historyPageIndex++
}
historyApi.fileHistoryList({fileId: id, username: this.$store.state.user.name, pageIndex: this.historyPageIndex, pageSize: this.historyPageSize}).then((res) => {
if (res.count && res.count > 0) {
this.fileHistoryList = res.data
this.$emit('update:hasHistoryVersion', true)
} else {
this.$emit('update:hasHistoryVersion', false)
this.loading = true
const params = {
...this.loadApiParams,
pageIndex: this.historyPageIndex,
pageSize: this.historyPageSize,
}
this.loadApiFunction(params).then(res => {
if (res.data && res.data.length > 0) {
this.fileHistoryList.push(...res.data)
}
this.finished = this.fileHistoryList.length >= res.count
if (this.loadApiFunction === historyApi.fileHistoryList) {
this.$emit('update:hasHistoryVersion', res.count > 0)
} else {
this.$emit('loadHistoryPathListSuccess', { res, pathname: this.loadApiParams.path })
}
}).catch(err => {
console.error('加载历史版本失败', err)
if (isLoadMore) this.historyPageIndex--
}).finally(() => {
this.loading = false
})
},
loadHistoryPathList(pathname) {
if (!this.$store.getters.token) {
return
resetState() {
this.fileHistoryList = []
this.historyPageIndex = 1
this.finished = false
this.loading = false
const scrollWrapper = this.$el ? this.$el.querySelector('.scrollbar-wrapper') : null
if (scrollWrapper) scrollWrapper.scrollTop = 0
},
handleScroll(e) {
const { scrollTop, scrollHeight, clientHeight } = e.target
if (scrollHeight - scrollTop - clientHeight < 100) {
this._internalLoadHistory(true)
}
historyApi.fileHistoryPathList({path: encodeURI(pathname), username: this.$store.state.user.name, pageIndex: this.historyPageIndex, pageSize: this.historyPageSize}).then((res) => {
this.fileHistoryList = res.data
this.$emit('loadHistoryPathListSuccess', {res: res, pathname: pathname})
})
},
onGlobalClick(event) {
let eventClass = event.target.getAttribute('class') || event.target.parentElement.getAttribute('class')
if (eventClass && eventClass.indexOf('history-version-btn') > -1) {
let eventClass = event.target.getAttribute('class') || (event.target.parentElement ? event.target.parentElement.getAttribute('class') : '')
if (eventClass && eventClass.includes('history-version-btn')) {
return
}
if (this.historyListPopoverVisible) {
this.$emit('update:historyListPopoverVisible', false)
}
if (event.target.getAttribute("data-tab") === "file") {
// 这段逻辑非常特定,必须保留
if (event.target.getAttribute('data-tab') === 'file') {
setTimeout(() => {
let parentDoc = document.querySelector('.component-only-office')
let doc = parentDoc.getElementsByTagName('iframe')[0].contentWindow.document
doc.getElementById('fm-btn-history').style.display = 'none'
if (parentDoc) {
let doc = parentDoc.getElementsByTagName('iframe')[0].contentWindow.document
let btn = doc.getElementById('fm-btn-history')
if (btn) btn.style.display = 'none'
}
})
}
},
popoverShow() {
this.$emit('update:historyListPopoverVisible', true)
},
showOrHidePopover() {
if (this.historyListPopoverVisible) {
this.$emit('update:historyListPopoverVisible', false)
} else {
this.$emit('update:historyListPopoverVisible', true)
}
},
popoverHide() {
this.$emit('update:historyListPopoverVisible', false)
},
viewHistoryFile(historyInfo, diff, recovery) {
this.$emit('viewHistoryFile', {historyInfo: historyInfo, diff: diff, recovery: recovery})
showOrHidePopover() {
this.$emit('update:historyListPopoverVisible', !this.historyListPopoverVisible)
},
viewHistoryFile(historyInfo, diff, recovery) {
this.$emit('viewHistoryFile', { historyInfo, diff, recovery })
},
cancelDelete(id) {
document.getElementById('deleteVersionBtn-'+id).click()
document.getElementById('deleteVersionBtn-' + id).click()
},
cancelRecovery(id) {
document.getElementById('recoveryVersionBtn-'+id).click()
document.getElementById('recoveryVersionBtn-' + id).click()
},
confirmRecoveryHistoryFile(historyInfo) {
if (!this.saved) {
this.$message({type: 'info', message: "请先保存当前修改的内容"})
this.$message({ type: 'info', message: '请先保存当前修改的内容' })
return
}
this.cancelRecovery(historyInfo.id)
this.$emit('update:historyOperationLoading', true)
historyApi.recoveryHistory({id: historyInfo.id}).then((res) => {
this.$emit('recoverySuccess', {historyInfo: historyInfo, result: res})
historyApi.recoveryHistory({ id: historyInfo.id }).then((res) => {
this.$emit('recoverySuccess', { historyInfo, result: res })
})
},
confirmDeleteHistoryFile(id) {
this.cancelDelete(id)
this.$emit('update:historyOperationLoading', true)
historyApi.deleteHistory({id: id}).then(() => {
historyApi.deleteHistory({ id: id }).then(() => {
this.$emit('update:historyOperationLoading', false)
this.$message({message: '删除成功',type: 'success'})
this.$message({ message: '删除成功', type: 'success' })
let deleteIndex = this.fileHistoryList.findIndex(fileHistory => fileHistory.id === id)
this.fileHistoryList.splice(deleteIndex, 1)
if (this.fileHistoryList.length < 1) {
this.$emit('update:hasHistoryVersion', false)
}
})
}
}
},
},
}
</script>
<style scoped>
<style>
/* 非 Scoped 样式 */
.file-history-popover {
padding: 0 !important;
border-radius: 8px !important;
background-color: rgba(255, 255, 255, .75);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
.el-popconfirm {
width: 310px;
}
}
.dark-file-history-popover {
background-color: rgba(46, 46, 46, .75);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
color: #b7b7b7;
border: 1px solid #2e2e30;
}
</style>
<style scoped>
.version-history-timeline {
height: 100%;
}
.dark-version-history-timeline {
.timeline-date {
color: #b7b7b7;
}
.timeline-icon {
background: #3e3e3e;
}
.timeline-operator {
color: #b7b7b7;
}
.version-filename {
color: #b7b7b7;
}
.timeline-line {
background: #3e3e3e;
}
.dark-button {
background: #3e3e3e;
border: 1px solid #3e3e3e;
color: #ffffff;
}
.dark-button:hover {
color: #409EFF;
background-color: #181818;
}
.el-avatar {
background: #181818;
}
}
.scrollbar-wrapper {
height: 400px;
overflow-y: auto;
position: relative;
}
.scrollbar-wrapper::-webkit-scrollbar {
width: 6px;
background: transparent;
}
.scrollbar-wrapper::-webkit-scrollbar-thumb {
background: rgba(180, 180, 180, 0.48);
border-radius: 6px;
}
.timeline-container-scrollbar {
padding: 10px 15px 10px 20px;
}
.timeline-item {
position: relative;
display: flex;
padding: 15px 0;
}
.timeline-icon {
position: relative;
z-index: 2;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: #f0f2f5;
}
.timeline-line {
position: absolute;
left: 20px;
top: 45px;
bottom: -35px;
width: 1px;
background-color: #e1e4e8;
z-index: 1;
}
.timeline-content {
margin-left: 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.timeline-date {
font-size: 14px;
color: #24292e;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.version-filename {
font-size: 12px;
color: #586069;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timeline-second-line {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
}
.timeline-operator {
color: #586069;
font-weight: bold;
}
.timeline-actions {
display: flex;
align-items: center;
gap: 10px;
}
.timeline-actions .el-button {
margin: 0 !important;
}
.timeline-loading, .timeline-empty, .timeline-loading-more {
text-align: center;
color: #909399;
padding: 20px 0;
font-size: 14px;
}
.el-popconfirm__main {
margin: 12px 0;
}
</style>

View File

@ -236,9 +236,9 @@ export default {
.timeline-line {
position: absolute;
left: 20px; /* (Avatar宽度 / 2) */
top: 45px; /* 大约是 Avatar 下方开始 */
top: 50px; /* 大约是 Avatar 下方开始 */
/* bottom: 0; */ /* 不再使用 bottom */
height: calc(100% - 40px); /* 从 Avatar 下方到底部 */
height: calc(100% - 30px); /* 从 Avatar 下方到底部 */
width: 1px;
background-color: #e1e4e8;
z-index: 1;

View File

@ -211,22 +211,43 @@ export default {
this.docEditorConfig.document.key = key ? key : this.fileKey
this.docEditor = new DocsAPI.DocEditor(this.id, this.docEditorConfig)
},
onRequestHistoryData(event, historyInfo, url) {
let historyConfig = {
"fileType": this.fileType,
"key": `${new Date(historyInfo.metadata.time).getTime()}-${SparkMD5.hash(this.file.id)}`,
"url": url,
"version": historyInfo.metadata.time,
onRequestHistoryData(event, historyInfo, historyUrl) {
let historyConfig;
if (historyInfo && historyUrl){
historyConfig = {
"fileType": this.fileType,
"key": `${new Date(historyInfo.metadata.time).getTime()}-${SparkMD5.hash(this.file.id)}`,
"url": historyUrl,
"version": historyInfo.metadata.time,
}
} else {
historyInfo = this.fileHistoryDateList.find(historyInfo => historyInfo.version === event.data)
console.log('historyInfo', historyInfo)
historyUrl = window.location.origin + fileConfig.previewHistoryUrl(historyInfo.key, this.$store.state.user.name, this.$store.state.user.token)
historyConfig = {
"fileType": this.fileType,
"key": historyInfo.key,
"url": historyUrl,
"version": historyInfo.version,
}
}
api.getOfficeJwt(historyConfig).then(res => {
historyConfig.token = res.data
this.docEditor.setHistoryData(historyConfig)
})
},
onRequestHistoryClose() {
console.log('onRequestHistoryClose')
this.reloadDocument()
},
onRequestHistory() {
this.docEditor.refreshHistory({
"currentVersion": 'currentVersion',
"history": []
api.getOfficeHistoryList({fileId: this.file.id, pageSize: 100, pageIndex: 1}).then(res => {
const currentVersion = res.data.length + 1
this.fileHistoryDateList = res.data
this.docEditor.refreshHistory({
"currentVersion": currentVersion,
"history": this.fileHistoryDateList
})
})
},
getType(type) {
@ -345,6 +366,7 @@ export default {
"onDocumentStateChange": this.onDocumentStateChange,
"onRequestHistory": this.onRequestHistory,
"onRequestHistoryData": this.onRequestHistoryData,
"onRequestHistoryClose": this.onRequestHistoryClose,
}
this.$nextTick(() => {
this.titleObserver = null

View File

@ -83,6 +83,9 @@
<el-form-item label="位置:" class="details-position">
<span><a :href="pathUrl" v-tooltip="{content: '文件所在位置', placement: 'bottom'}">{{ filepath }}</a></span>
</el-form-item>
<el-form-item label="SHA265:">
<span> {{ file.etag }}</span>
</el-form-item>
<el-form-item label="上传时间:">
<span>{{ file.uploadDate }}</span>
</el-form-item>

View File

@ -376,48 +376,6 @@ input {
}
.file-history-popover {
.el-popconfirm {
width: 310px;
}
}
.dark-file-history-popover {
background-color: #202021;
color: #b7b7b7;
border: 1px solid #2e2e30;
.el-popconfirm {
width: 310px;
}
}
.dark-file-history-list.el-table--enable-row-hover {
.el-table__body tr:hover>td {
background-color: #2e2e30;
color: #fff;
}
}
.dark-file-history-list.el-table {
background-color: #202021;
tr,th,td {
background-color: #202021;
color: #b7b7b7;
border-bottom: 1px solid #3e3e3e;
}
.dark-button {
background: #3e3e3e;
border: 1px solid #3e3e3e;
color: #ffffff;
}
.dark-button:hover {
color: #409EFF;
background-color: #181818;
}
}
.dark-file-history-list.el-table::before {
background-color: #3e3e3e;
}
kbd {
display: inline-block;
margin-left: 3px;