Improve Recent documents (#15824)

*  Improve Recent documents
基于文档最近浏览时间进行排序

*  支持显示最近关闭文档

*  支持显示最近关闭文档

*  支持显示最近关闭文档

*  支持显示最近关闭文档

*  支持显示最近关闭文档

*  支持显示最近关闭文档

* 支持Ctrl+Shift+T打开最近关闭的文档

* 🎨 clean code

* 🔥 移除表格插入行/列的默认快捷键

*  最近文档支持显示最近修改文档

* 🎨

*  最近文档支持查看最近打开

* 🎨

* 

* Update win-build.bat
This commit is contained in:
Achuan-2 2025-10-24 11:12:14 +08:00 committed by GitHub
parent 7e1306cab9
commit d9e0c56a47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 10579 additions and 10202 deletions

View file

@ -1361,6 +1361,10 @@
"uploadError": "خطأ في الرفع",
"uploading": "يتم الرفع...",
"wysiwyg": "‫ما تراه هو ما تحصل عليه (WYSIWYG)",
"recentViewed": "تم عرضها مؤخراً",
"recentOpened": "تم فتحها مؤخراً",
"recentClosed": "تم إغلاقها مؤخراً",
"recentModified": "تم تعديلها مؤخراً",
"_label": "العربية",
"_time": {
"albl": "من قبل",

File diff suppressed because it is too large Load diff

View file

@ -1361,6 +1361,10 @@
"uploadError": "upload error",
"uploading": "Uploading...",
"wysiwyg": "WYSIWYG",
"recentViewed": "Recently Viewed",
"recentOpened": "Recently Opened",
"recentClosed": "Recently Closed",
"recentModified": "Recently Modified",
"_label": "English",
"_time": {
"albl": "ago",

View file

@ -1361,6 +1361,10 @@
"uploadError": "error de subida",
"uploading": "Subiendo...",
"wysiwyg": "WYSIWYG",
"recentViewed": "Visto recientemente",
"recentOpened": "Abierto recientemente",
"recentClosed": "Cerrado recientemente",
"recentModified": "Modificado recientemente",
"_label": "Español",
"_time": {
"albl": "hace",

View file

@ -1361,6 +1361,10 @@
"uploadError": "erreur de transfert",
"uploading": "Transfert en cours",
"wysiwyg": "WYSIWYG",
"recentViewed": "Récemment consulté",
"recentOpened": "Récemment ouvert",
"recentClosed": "Récemment fermé",
"recentModified": "Récemment modifié",
"_label": "Français",
"_time": {
"albl": "Précédemment",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1361,6 +1361,10 @@
"uploadError": "アップロードエラー",
"uploading": "アップロード中...",
"wysiwyg": "WYSIWYG",
"recentViewed": "最近閲覧",
"recentOpened": "最近開いた",
"recentClosed": "最近閉じる",
"recentModified": "最近更新",
"_label": "日本語",
"_time": {
"albl": "前",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1361,6 +1361,10 @@
"uploadError": "上傳錯誤",
"uploading": "上傳中...",
"wysiwyg": "所見即所得",
"recentViewed": "最近瀏覽",
"recentOpened": "最近開啟",
"recentClosed": "最近關閉",
"recentModified": "最近更新",
"_label": "繁體中文",
"_time": {
"albl": "前",

View file

@ -1361,6 +1361,10 @@
"uploadError": "上传错误",
"uploading": "上传中...",
"wysiwyg": "所见即所得",
"recentViewed": "最近浏览",
"recentOpened": "最近打开",
"recentClosed": "最近关闭",
"recentModified": "最近修改",
"_label": "简体中文",
"_time": {
"albl": "前",

View file

@ -1532,6 +1532,19 @@ export const windowKeyDown = (app: App, event: KeyboardEvent) => {
return;
}
if (matchHotKey("⇧⌘T", event)) {
if ((window as any).siyuan.closedTabs && (window as any).siyuan.closedTabs.length > 0) {
const closedTab = (window as any).siyuan.closedTabs.pop();
openFileById({
app,
id: closedTab.id,
action: [Constants.CB_GET_FOCUS, Constants.CB_GET_SCROLL]
});
}
event.preventDefault();
return;
}
if (matchHotKey(window.siyuan.config.keymap.general.goToTab1.custom, event) && !event.repeat) {
switchTabByIndex(0);
event.preventDefault();

View file

@ -10,10 +10,18 @@ import {focusByRange} from "../protyle/util/selection";
import {hasClosestByClassName} from "../protyle/util/hasClosest";
import {hideElements} from "../protyle/ui/hideElements";
const getHTML = async (data: { rootID: string, icon: string, title: string }[], element: Element, key?: string) => {
const getHTML = async (data: { rootID: string, icon: string, title: string, viewedAt?: number, closedAt?: number, openAt?: number, updated?: number }[], element: Element, key?: string, sortBy: "viewedAt" | "closedAt" | "openAt" | "updated" = "viewedAt") => {
let tabHtml = "";
let index = 0;
data.forEach((item) => {
// 根据排序字段对数据进行排序
const sortedData = [...data].sort((a, b) => {
const aValue = a[sortBy] || 0;
const bValue = b[sortBy] || 0;
return bValue - aValue; // 降序排序
});
sortedData.forEach((item) => {
if (!key || item.title.toLowerCase().includes(key.toLowerCase())) {
tabHtml += `<li data-index="${index}" data-node-id="${item.rootID}" class="b3-list-item${index === 0 ? " b3-list-item--focus" : ""}">
${unicode2Emoji(item.icon || window.siyuan.storage[Constants.LOCAL_IMAGES].file, "b3-list-item__graphic", true)}
@ -77,7 +85,7 @@ export const openRecentDocs = () => {
hideElements(["dialog"]);
return;
}
fetchPost("/api/storage/getRecentDocs", {}, (response) => {
fetchPost("/api/storage/getRecentDocs", {sortBy: "viewedAt"}, (response) => {
let range: Range;
if (getSelection().rangeCount > 0) {
range = getSelection().getRangeAt(0);
@ -91,6 +99,14 @@ export const openRecentDocs = () => {
<svg class="b3-form__icon-icon"><use xlink:href="#iconSearch"></use></svg>
<input placeholder="${window.siyuan.languages.search}" class="b3-text-field fn__block b3-form__icon-input">
</div>
<div class="fn__flex-center fn__ml8">
<select class="b3-select fn__size200" id="recentDocsSort">
<option value="viewedAt">${window.siyuan.languages.recentViewed}</option>
<option value="updated">${window.siyuan.languages.recentModified}</option>
<option value="openAt">${window.siyuan.languages.recentOpened}</option>
<option value="closedAt">${window.siyuan.languages.recentClosed}</option>
</select>
</div>
</div>`,
content: `<div class="fn__flex-column switch-doc">
<div class="fn__flex fn__flex-1" style="overflow:auto;"></div>
@ -106,13 +122,13 @@ export const openRecentDocs = () => {
const searchElement = dialog.element.querySelector("input");
searchElement.focus();
searchElement.addEventListener("compositionend", () => {
getHTML(response.data, dialog.element, searchElement.value);
getHTML(response.data, dialog.element, searchElement.value, sortSelect.value as "viewedAt" | "closedAt" | "openAt" | "updated");
});
searchElement.addEventListener("input", (event: InputEvent) => {
if (event.isComposing) {
return;
}
getHTML(response.data, dialog.element, searchElement.value);
getHTML(response.data, dialog.element, searchElement.value, sortSelect.value as "viewedAt" | "closedAt" | "openAt" | "updated");
});
dialog.element.setAttribute("data-key", Constants.DIALOG_RECENTDOCS);
dialog.element.addEventListener("click", (event) => {
@ -125,6 +141,45 @@ export const openRecentDocs = () => {
event.preventDefault();
}
});
// 添加排序下拉框事件监听
const sortSelect = dialog.element.querySelector("#recentDocsSort") as HTMLSelectElement;
sortSelect.addEventListener("change", () => {
// 重新调用API获取排序后的数据
if (sortSelect.value === "updated") {
// 使用SQL查询获取最近修改的文档
const data = {
stmt: "SELECT * FROM blocks WHERE type = 'd' ORDER BY updated DESC LIMIT 33"
};
fetchSyncPost("/api/query/sql", data).then((sqlResponse) => {
if (sqlResponse.data && sqlResponse.data.length > 0) {
// 转换SQL查询结果格式
const recentModifiedDocs = sqlResponse.data.map((block: any) => {
// 从ial中解析icon
let icon = "";
if (block.ial) {
const iconMatch = block.ial.match(/icon="([^"]*)"/);
if (iconMatch) {
icon = iconMatch[1];
}
}
return {
rootID: block.id,
icon: icon,
title: block.content,
updated: block.updated
};
});
getHTML(recentModifiedDocs, dialog.element, searchElement.value, "updated");
}
});
} else {
fetchPost("/api/storage/getRecentDocs", {sortBy: sortSelect.value}, (newResponse) => {
getHTML(newResponse.data, dialog.element, searchElement.value, sortSelect.value as "viewedAt" | "closedAt" | "openAt");
});
}
});
getHTML(response.data, dialog.element);
});
};

View file

@ -572,10 +572,10 @@ export abstract class Constants {
checkToggle: {default: "⌘↩", custom: "⌘↩"},
},
table: {
insertRowAbove: {default: "⇧⌘T", custom: "⇧⌘T"},
insertRowBelow: {default: "⇧⌘D", custom: "⇧⌘D"},
insertColumnLeft: {default: "⇧⌘L", custom: "⇧⌘L"},
insertColumnRight: {default: "⇧⌘R", custom: "⇧⌘R"},
insertRowAbove: {default: "", custom: ""},
insertRowBelow: {default: "", custom: ""},
insertColumnLeft: {default: "", custom: ""},
insertColumnRight: {default: "", custom: ""},
moveToUp: {default: "⌥⌘T", custom: "⌥⌘T"},
moveToDown: {default: "⌥⌘B", custom: "⌥⌘B"},
moveToLeft: {default: "⌥⌘L", custom: "⌥⌘L"},

View file

@ -56,6 +56,10 @@ export const openFileById = async (options: {
showMessage(response.msg);
return;
}
// 更新文档浏览时间
fetchPost("/api/storage/updateRecentDocViewTime", {rootID: response.data.rootID});
return openFile({
app: options.app,
fileName: response.data.rootTitle,

View file

@ -30,6 +30,7 @@ import {Search} from "../search";
import {showMessage} from "../dialog/message";
import {openFileById, updatePanelByEditor} from "../editor/util";
import {scrollCenter} from "../util/highlightById";
import {fetchPost} from "../util/fetch";
import {getAllModels} from "./getAll";
import {clearCounter} from "./status";
import {saveScroll} from "../protyle/scroll/saveScroll";
@ -555,6 +556,9 @@ export class Wnd {
}
// focusin 触发前layout__wnd--active 和 tab 已设置,需在调用里面更新
if (update) {
// 更新文档浏览时间
fetchPost("/api/storage/updateRecentDocViewTime", {rootID: currentTab.model.editor.protyle.block.rootID});
updatePanelByEditor({
protyle: currentTab.model.editor.protyle,
focus: true,
@ -632,6 +636,12 @@ export class Wnd {
if (tab.callback) {
tab.callback(tab);
}
// 当文档第一次加载到页签时更新 openAt 时间
if (tab.model instanceof Editor && tab.model.editor?.protyle?.block?.rootID) {
fetchPost("/api/storage/updateRecentDocOpenTime", {rootID: tab.model.editor.protyle.block.rootID});
}
// 移除 centerLayout 中的 empty
if (this.parent.type === "center" && this.children.length === 2 && !this.children[0].headElement) {
this.removeTab(this.children[0].id);
@ -781,6 +791,14 @@ export class Wnd {
}
if (item.model instanceof Editor) {
saveScroll(item.model.editor.protyle);
// 更新文档关闭时间
fetchPost("/api/storage/updateRecentDocCloseTime", {rootID: item.model.editor.protyle.block.rootID});
if (!(window as any).siyuan.closedTabs) {
(window as any).siyuan.closedTabs = [];
}
(window as any).siyuan.closedTabs.push({
id: item.model.editor.protyle.block.rootID
});
}
if (this.children.length === 1) {
this.destroyModel(this.children[0].model);

View file

@ -52,6 +52,9 @@ export const openMobileFileById = (app: App, id: string, action: TProtyleAction[
showMessage(data.msg);
return;
}
// 更新文档浏览时间
fetchPost("/api/storage/updateRecentDocViewTime", {rootID: data.data.rootID});
const protyleOptions: IProtyleOptions = {
blockId: id,
rootId: data.data.rootID,

View file

@ -8,7 +8,7 @@ import {openMobileFileById} from "../editor";
import {App} from "../../index";
export const getRecentDocs = (app: App) => {
fetchPost("/api/storage/getRecentDocs", {}, (response) => {
fetchPost("/api/storage/getRecentDocs", {sortBy: "viewedAt"}, (response) => {
let html = "";
response.data.forEach((item: any, index: number) => {
html += `<li data-index="${index}" data-node-id="${item.rootID}" class="b3-list-item${index === 0 ? " b3-list-item--focus" : ""}">

View file

@ -78,6 +78,10 @@ func ServeAPI(ginServer *gin.Engine) {
ginServer.Handle("POST", "/api/storage/getCriteria", model.CheckAuth, getCriteria)
ginServer.Handle("POST", "/api/storage/removeCriterion", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeCriterion)
ginServer.Handle("POST", "/api/storage/getRecentDocs", model.CheckAuth, getRecentDocs)
ginServer.Handle("POST", "/api/storage/updateRecentDocViewTime", model.CheckAuth, updateRecentDocViewTime)
ginServer.Handle("POST", "/api/storage/updateRecentDocCloseTime", model.CheckAuth, updateRecentDocCloseTime)
ginServer.Handle("POST", "/api/storage/updateRecentDocOpenTime", model.CheckAuth, updateRecentDocOpenTime)
ginServer.Handle("POST", "/api/storage/getOutlineStorage", model.CheckAuth, getOutlineStorage)
ginServer.Handle("POST", "/api/storage/setOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setOutlineStorage)
ginServer.Handle("POST", "/api/storage/removeOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeOutlineStorage)

View file

@ -29,7 +29,18 @@ func getRecentDocs(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
data, err := model.GetRecentDocs()
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
// 获取排序参数
sortBy := "viewedAt" // 默认按浏览时间排序
if arg["sortBy"] != nil {
sortBy = arg["sortBy"].(string)
}
data, err := model.GetRecentDocs(sortBy)
if err != nil {
ret.Code = -1
ret.Msg = err.Error()
@ -236,3 +247,59 @@ func removeOutlineStorage(c *gin.Context) {
return
}
}
func updateRecentDocViewTime(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
rootID := arg["rootID"].(string)
err := model.UpdateRecentDocViewTime(rootID)
if err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
}
func updateRecentDocOpenTime(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
rootID := arg["rootID"].(string)
err := model.UpdateRecentDocOpenTime(rootID)
if err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
}
func updateRecentDocCloseTime(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
rootID := arg["rootID"].(string)
err := model.UpdateRecentDocCloseTime(rootID)
if err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
}

View file

@ -21,7 +21,9 @@ import (
"os"
"path"
"path/filepath"
"sort"
"sync"
"time"
"github.com/88250/gulu"
"github.com/88250/lute/parse"
@ -32,9 +34,12 @@ import (
)
type RecentDoc struct {
RootID string `json:"rootID"`
Icon string `json:"icon"`
Title string `json:"title"`
RootID string `json:"rootID"`
Icon string `json:"icon"`
Title string `json:"title"`
ViewedAt int64 `json:"viewedAt"` // 浏览时间字段
ClosedAt int64 `json:"closedAt"` // 关闭时间字段
OpenAt int64 `json:"openAt"` // 文档第一次从文档树加载到页签的时间
}
type OutlineDoc struct {
@ -70,9 +75,12 @@ func RemoveRecentDoc(ids []string) {
func setRecentDocByTree(tree *parse.Tree) {
recentDoc := &RecentDoc{
RootID: tree.Root.ID,
Icon: tree.Root.IALAttr("icon"),
Title: tree.Root.IALAttr("title"),
RootID: tree.Root.ID,
Icon: tree.Root.IALAttr("icon"),
Title: tree.Root.IALAttr("title"),
ViewedAt: time.Now().Unix(), // 使用当前时间作为浏览时间
ClosedAt: 0, // 初始化关闭时间为0表示未关闭
OpenAt: time.Now().Unix(), // 设置文档打开时间
}
recentDocLock.Lock()
@ -99,10 +107,95 @@ func setRecentDocByTree(tree *parse.Tree) {
return
}
func GetRecentDocs() (ret []*RecentDoc, err error) {
// 更新文档打开时间(只在第一次从文档树加载到页签时调用)
func UpdateRecentDocOpenTime(rootID string) error {
recentDocLock.Lock()
defer recentDocLock.Unlock()
return getRecentDocs()
recentDocs, err := getRecentDocs()
if err != nil {
return err
}
// 查找文档并更新打开时间
found := false
for _, doc := range recentDocs {
if doc.RootID == rootID {
doc.OpenAt = time.Now().Unix()
found = true
break
}
}
if found {
err = setRecentDocs(recentDocs)
}
return err
}
// 更新文档浏览时间
func UpdateRecentDocViewTime(rootID string) error {
recentDocLock.Lock()
defer recentDocLock.Unlock()
recentDocs, err := getRecentDocs()
if err != nil {
return err
}
// 查找文档并更新浏览时间
found := false
for _, doc := range recentDocs {
if doc.RootID == rootID {
doc.ViewedAt = time.Now().Unix()
found = true
break
}
}
if found {
// 按浏览时间降序排序
sort.Slice(recentDocs, func(i, j int) bool {
return recentDocs[i].ViewedAt > recentDocs[j].ViewedAt
})
err = setRecentDocs(recentDocs)
}
return err
}
// 更新文档关闭时间
func UpdateRecentDocCloseTime(rootID string) error {
recentDocLock.Lock()
defer recentDocLock.Unlock()
recentDocs, err := getRecentDocs()
if err != nil {
return err
}
// 查找文档并更新关闭时间
found := false
for _, doc := range recentDocs {
if doc.RootID == rootID {
doc.ClosedAt = time.Now().Unix()
found = true
break
}
}
if found {
err = setRecentDocs(recentDocs)
}
return err
}
func GetRecentDocs(sortBy ...string) (ret []*RecentDoc, err error) {
recentDocLock.Lock()
defer recentDocLock.Unlock()
return getRecentDocs(sortBy...)
}
func setRecentDocs(recentDocs []*RecentDoc) (err error) {
@ -127,7 +220,7 @@ func setRecentDocs(recentDocs []*RecentDoc) (err error) {
return
}
func getRecentDocs() (ret []*RecentDoc, err error) {
func getRecentDocs(sortBy ...string) (ret []*RecentDoc, err error) {
tmp := []*RecentDoc{}
dataPath := filepath.Join(util.DataDir, "storage/recent-doc.json")
if !filelock.IsExist(dataPath) {
@ -159,9 +252,77 @@ func getRecentDocs() (ret []*RecentDoc, err error) {
notExists = append(notExists, doc.RootID)
}
}
if 0 < len(notExists) {
setRecentDocs(ret)
}
// 根据排序参数进行排序
if len(sortBy) > 0 {
switch sortBy[0] {
case "closedAt":
// 按关闭时间排序
sort.Slice(ret, func(i, j int) bool {
if ret[i].ClosedAt == 0 && ret[j].ClosedAt == 0 {
// 如果都没有关闭时间,按浏览时间排序
return ret[i].ViewedAt > ret[j].ViewedAt
}
if ret[i].ClosedAt == 0 {
return false // 没有关闭时间的排在后面
}
if ret[j].ClosedAt == 0 {
return true // 有关闭时间的排在前面
}
return ret[i].ClosedAt > ret[j].ClosedAt
})
case "openAt":
// 按打开时间排序
sort.Slice(ret, func(i, j int) bool {
if ret[i].OpenAt == 0 && ret[j].OpenAt == 0 {
// 如果都没有打开时间按ID时间排序ID包含时间信息
return ret[i].RootID > ret[j].RootID
}
if ret[i].OpenAt == 0 {
return false // 没有打开时间的排在后面
}
if ret[j].OpenAt == 0 {
return true // 有打开时间的排在前面
}
return ret[i].OpenAt > ret[j].OpenAt
})
default:
// 默认按浏览时间排序
sort.Slice(ret, func(i, j int) bool {
if ret[i].ViewedAt == 0 && ret[j].ViewedAt == 0 {
// 如果都没有浏览时间按ID时间排序ID包含时间信息
return ret[i].RootID > ret[j].RootID
}
if ret[i].ViewedAt == 0 {
return false // 没有浏览时间的排在后面
}
if ret[j].ViewedAt == 0 {
return true // 有浏览时间的排在前面
}
return ret[i].ViewedAt > ret[j].ViewedAt
})
}
} else {
// 默认按浏览时间降序排序如果ViewedAt为0则使用文档创建时间
sort.Slice(ret, func(i, j int) bool {
if ret[i].ViewedAt == 0 && ret[j].ViewedAt == 0 {
// 如果都没有浏览时间按ID时间排序ID包含时间信息
return ret[i].RootID > ret[j].RootID
}
if ret[i].ViewedAt == 0 {
return false // 没有浏览时间的排在后面
}
if ret[j].ViewedAt == 0 {
return true // 有浏览时间的排在前面
}
return ret[i].ViewedAt > ret[j].ViewedAt
})
}
return
}