From 9d36c5d654355c18de700cba24e83611751f6fa0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 20 Sep 2026 22:54:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=AB=AF=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E9=87=8D=E6=9E=84=E4=B8=BA=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E5=B9=B6=E9=87=8D=E5=81=9A=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E7=9C=8B=E9=98=85=E8=AF=BB=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左侧导航轨 + 主视图 + 常驻详情检查器,替换原左右两栏平铺卡片结构 - 拆分为 TaskView / TaskRow / SearchView / SettingsView / DetailPanel / NavRail / AuthScreen,删除 DashBoard、Side、HentaiSearch - 在线看改为全屏阅读器:适应高度/宽度、常驻翻页栏、缩略图与标题信息, 翻页改为状态驱动,不再点击内部 DOM;补键盘快捷键与大图查看 - 提交弹层默认选中原图(无原图时选体积最大的一个) - 管理员可见「下载人」筛选与详情里的下载人昵称,判定改用后端 isAdmin - store.deleteGallery 返回 promise,便于删除后清理选中态 - 新增 thumbnail 工具与 4 项单测 --- src/App.vue | 75 ++- src/components/AppIcon.vue | 6 + src/components/AuthScreen.vue | 43 ++ src/components/DashBoard.vue | 423 ------------- src/components/DetailPanel.vue | 254 ++++++++ src/components/HentaiSearch.vue | 131 ---- src/components/NavRail.vue | 73 +++ src/components/OnlineReader.vue | 538 ++++++++++++---- src/components/SearchView.vue | 209 +++++++ src/components/SettingsView.vue | 229 +++++++ src/components/Side.vue | 403 ------------ src/components/TaskRow.vue | 86 +++ src/components/TaskView.vue | 224 +++++++ src/store/index.js | 55 +- src/styles/theme.css | 1028 ++++++++++++++++++++++--------- src/utils/thumbnail.js | 27 + tests/thumbnail.test.mjs | 33 + 17 files changed, 2447 insertions(+), 1390 deletions(-) create mode 100644 src/components/AuthScreen.vue delete mode 100644 src/components/DashBoard.vue create mode 100644 src/components/DetailPanel.vue delete mode 100644 src/components/HentaiSearch.vue create mode 100644 src/components/NavRail.vue create mode 100644 src/components/SearchView.vue create mode 100644 src/components/SettingsView.vue delete mode 100644 src/components/Side.vue create mode 100644 src/components/TaskRow.vue create mode 100644 src/components/TaskView.vue create mode 100644 src/utils/thumbnail.js create mode 100644 tests/thumbnail.test.mjs diff --git a/src/App.vue b/src/App.vue index 02e79ed..b17ac4a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,20 @@ + - +
+ +
+ + + +
+ +
+ + + diff --git a/src/components/AppIcon.vue b/src/components/AppIcon.vue index 3a17512..a26f646 100644 --- a/src/components/AppIcon.vue +++ b/src/components/AppIcon.vue @@ -18,6 +18,12 @@ const PATHS = { "chevrons-right": ["M13 17l5-5-5-5", "M6 17l5-5-5-5"], sun: ["M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M12 2v2", "M12 20v2", "M4.5 4.5l1.4 1.4", "M18.1 18.1l1.4 1.4", "M2 12h2", "M20 12h2", "M4.5 19.5l1.4-1.4", "M18.1 5.9l1.4-1.4"], moon: ["M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"], + list: ["M8 6h13", "M8 12h13", "M8 18h13", "M3 6h.01", "M3 12h.01", "M3 18h.01"], + settings: ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"], + sort: ["M11 5h10", "M11 9h7", "M11 13h4", "M3 17l3 3 3-3", "M6 18V4"], + clock: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 7v5l3 2"], + close: ["M18 6L6 18", "M6 6l12 12"], + image: ["M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", "M8.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z", "M21 15l-5-5L5 21"], }; const props = defineProps({ diff --git a/src/components/AuthScreen.vue b/src/components/AuthScreen.vue new file mode 100644 index 0000000..1f71fb5 --- /dev/null +++ b/src/components/AuthScreen.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/DashBoard.vue b/src/components/DashBoard.vue deleted file mode 100644 index daa4491..0000000 --- a/src/components/DashBoard.vue +++ /dev/null @@ -1,423 +0,0 @@ - - - - - diff --git a/src/components/DetailPanel.vue b/src/components/DetailPanel.vue new file mode 100644 index 0000000..8c12a91 --- /dev/null +++ b/src/components/DetailPanel.vue @@ -0,0 +1,254 @@ + + + diff --git a/src/components/HentaiSearch.vue b/src/components/HentaiSearch.vue deleted file mode 100644 index 7980ff7..0000000 --- a/src/components/HentaiSearch.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/src/components/NavRail.vue b/src/components/NavRail.vue new file mode 100644 index 0000000..179992d --- /dev/null +++ b/src/components/NavRail.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/OnlineReader.vue b/src/components/OnlineReader.vue index 2eb0247..11b0e72 100644 --- a/src/components/OnlineReader.vue +++ b/src/components/OnlineReader.vue @@ -1,142 +1,434 @@ + diff --git a/src/components/SearchView.vue b/src/components/SearchView.vue new file mode 100644 index 0000000..2c44dca --- /dev/null +++ b/src/components/SearchView.vue @@ -0,0 +1,209 @@ + + + diff --git a/src/components/SettingsView.vue b/src/components/SettingsView.vue new file mode 100644 index 0000000..58662d1 --- /dev/null +++ b/src/components/SettingsView.vue @@ -0,0 +1,229 @@ + + + diff --git a/src/components/Side.vue b/src/components/Side.vue deleted file mode 100644 index cc0fd9a..0000000 --- a/src/components/Side.vue +++ /dev/null @@ -1,403 +0,0 @@ - - - - - diff --git a/src/components/TaskRow.vue b/src/components/TaskRow.vue new file mode 100644 index 0000000..8f02ed2 --- /dev/null +++ b/src/components/TaskRow.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/components/TaskView.vue b/src/components/TaskView.vue new file mode 100644 index 0000000..5f2165c --- /dev/null +++ b/src/components/TaskView.vue @@ -0,0 +1,224 @@ + + + diff --git a/src/store/index.js b/src/store/index.js index 15052d9..6633bc2 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -154,7 +154,8 @@ const actions = { }) }, deleteGallery(context, gid){ - axios.delete(GalleryManageUrl, { + // Returned so callers can clear a selection once the task is really gone. + return axios.delete(GalleryManageUrl, { params:{ AuthCode:context.state.AuthCode, gid }}).then((res) => { @@ -315,6 +316,8 @@ const mutations = { state.AuthCode = data.AuthCode state.userId = data.userId state.username = data.username + state.isAdmin = Boolean(data.isAdmin) + state.downloaderFilter = null state.isAuth = true ElMessage("验证成功,加载中") }, @@ -381,6 +384,8 @@ const mutations = { }, _setCategory(state, category){ state.category = category + // 换分类后旧的下载人筛选可能在新分类里根本不存在,先清掉。 + state.downloaderFilter = null confirmCurrentTask(state) sortTasks(state) }, @@ -412,8 +417,18 @@ const mutations = { _closeReader(state){ state.isReading = false; }, - _changeThumbnailGallery(state, gallery){ - state.thumbnailGallery = gallery + _setActiveView(state, view){ + state.activeView = view + }, + _selectGallery(state, gallery){ + state.selectedGallery = gallery || null + }, + _setDownloaderFilter(state, downloader){ + state.downloaderFilter = downloader === null || downloader === undefined + ? null + : Number(downloader) + // 筛完可能不足一页,回到第一页避免停在空页上。 + state.page = 1 } } @@ -422,7 +437,6 @@ const state = { totalGalleryTask: [], //存放数据的数组 chosenGallery: false, //准备下载 - thumbnailGallery: {}, //缩略图 collectGallery: [], //收藏 downloadGallery: [], //下载 isSearch: false, //用于决定是否显示搜索结果 @@ -452,25 +466,28 @@ const state = { galleryNameType: 'shortName', //名字类型 shortName name currentTasks: [], //当前任务 weekUsed: {}, //每周用量 + + activeView: 'tasks', //当前视图 tasks search settings + selectedGallery: null, //右侧详情面板选中的任务 + isAdmin: false, //管理员(后端 validate 下发) + downloaderFilter: null, //管理员按下载人筛选,null 表示全部 } const getters = { + /** 当前筛选条件命中的完整任务列表(未分页),用于计数。 */ + filteredTasks(state){ + return activeTaskList(state) + }, currentTasks(state){ - if(state.isSearch) - return state.searchTask.slice((state.page - 1) * state.length, state.page * state.length) - else - return state.currentTasks.slice((state.page - 1) * state.length, state.page * state.length) + const tasks = activeTaskList(state) + return tasks.slice((state.page - 1) * state.length, state.page * state.length) }, min(){ return 1 }, max(state){ let max = 0 - let tasks - if(state.isSearch) - tasks = state.searchTask - else - tasks = state.currentTasks + const tasks = activeTaskList(state) if(!tasks) return 1 @@ -490,6 +507,18 @@ export default new vuex.Store({ getters }) +/** + * 当前视图实际要展示的任务。 + * 下载人筛选只在管理员生效,作用于「我的下载/收藏/全部」三种分类的结果。 + */ +function activeTaskList(state){ + if(state.isSearch) + return state.searchTask + if(state.isAdmin && state.downloaderFilter !== null) + return state.currentTasks.filter(task => task.downloader === state.downloaderFilter) + return state.currentTasks +} + function getShortname(name){ if(name === null){ console.log(name) diff --git a/src/styles/theme.css b/src/styles/theme.css index 2bb74e3..64ee70d 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1,18 +1,18 @@ /* ========== Design Tokens ========== */ :root { /* Neutrals: warm-tinted grey, keeps the UI quiet on long sessions. */ - --bg-body: #f5f5f4; + --bg-body: #f1f2f5; --bg-surface: #ffffff; --bg-card: #ffffff; - --bg-hover: #f2f2f0; - --bg-sunken: #fafaf9; + --bg-hover: #f2f3f6; + --bg-sunken: #f7f8fa; --text-primary: #1f2328; --text-secondary: #6b7280; --text-disabled: #c2c6cd; - --border-color: #e4e4e2; - --border-strong: #d5d6d3; + --border-color: #e4e5e8; + --border-strong: #d5d7dc; --border-radius: 8px; --border-radius-sm: 6px; @@ -34,6 +34,9 @@ --shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06); --shadow-elevated: 0 8px 24px rgba(16, 24, 40, 0.12); + --rail-width: 76px; + --inspector-width: clamp(300px, 24vw, 380px); + --transition-fast: 0.15s ease; --transition-normal: 0.25s ease; } @@ -97,6 +100,20 @@ body { min-height: 100%; } +.view-bar-spacer { + flex: 1; + min-width: 8px; +} + +.section-title { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.04em; + text-transform: uppercase; +} + /* ========== Auth Screen ========== */ .auth-container { position: fixed; @@ -177,113 +194,517 @@ body { margin-top: 4px; } -/* ========== Page Shell ========== */ -.app-layout { +/* ========== App Shell ========== */ +.app { + display: flex; height: 100vh; overflow: hidden; -} - -.side-area { - background: var(--bg-surface); - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.main-area { - padding: 0; - overflow-y: auto; - overflow-x: hidden; background-color: var(--bg-body); } -.section-title { - margin: 0; - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - letter-spacing: 0.04em; - text-transform: uppercase; -} - -/* ========== Task List (left column) ========== */ -.scroll-panel { +.workspace { flex: 1; - min-height: 0; + min-width: 0; display: flex; flex-direction: column; - padding: 16px 16px 0; - overflow: hidden; + padding: 16px; } -.panel-head { +/* ========== Navigation Rail ========== */ +.nav-rail { + flex: none; + width: var(--rail-width); display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - gap: 12px; - padding-bottom: 12px; + gap: 6px; + padding: 14px 8px; + background: var(--bg-surface); + border-right: 1px solid var(--border-color); +} + +.nav-brand { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 9px; + background: var(--accent); + color: #fff; + font-size: 16px; + font-weight: 700; + margin-bottom: 14px; flex: none; } -.panel-head .head-meta { - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; -} - -.panel-head .panel-head-spacer { - flex: 1; - min-width: 8px; -} - -.table-scroll { - flex: 1; - min-height: 0; -} - -.task-table { +.nav-items { + display: flex; + flex-direction: column; + gap: 4px; width: 100%; } -.task-table .el-table__cell { - padding: 10px 0; +.nav-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 9px 4px 7px; + border: 0; + border-radius: var(--border-radius); + background: none; + color: var(--text-secondary); + font-family: inherit; + font-size: 11px; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); } -.task-table .el-table__body-wrapper, -.task-table .el-table__header-wrapper { - overflow-x: hidden; -} - -.task-table .cell { - padding-left: 10px; - padding-right: 10px; -} - -.task-name { - font-size: 14px; +.nav-item:hover { + background: var(--bg-hover); color: var(--text-primary); - line-height: 1.45; - word-break: break-word; - /* Two-line clamp keeps every row the same height for easy scanning. */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; +} + +.nav-item.is-active { + background: var(--accent-soft); + color: var(--accent); +} + +.nav-badge { + position: absolute; + top: 4px; + right: 12px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; +} + +.nav-label { + font-size: 11px; + line-height: 1; +} + +.nav-foot { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 100%; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + +.nav-connection { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--status-warning); +} + +.nav-connection.is-connected { + color: var(--status-success); +} + +.connection-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; + flex: none; +} + +.nav-user { + max-width: 100%; + font-size: 11px; + color: var(--text-secondary); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ========== Views ========== */ +.view { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); overflow: hidden; } -.task-size { - display: block; - margin-top: 2px; +.view-bar { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); +} + +.view-title { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.view-count { font-size: 12px; color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.connection { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--status-warning); + white-space: nowrap; +} + +.connection.is-connected { + color: var(--status-success); +} + +/* ========== Filter Bar ========== */ +.filter-bar { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-color); +} + +.filter { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 8px 0 10px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-surface); +} + +.filter-label { + font-size: 11px; + color: var(--text-secondary); + flex: none; +} + +.filter-select { + width: 110px; +} + +.filter-select .el-select__wrapper { + min-height: 22px; + padding: 0; + box-shadow: none; + background: transparent; +} + +.filter-select .el-select__placeholder, +.filter-select .el-select__selected-item { + font-size: 13px; + color: var(--text-primary); +} + +.filter-select-sort { + width: 118px; +} + +.filter-select-name { + width: 96px; +} + +.filter-select-downloader { + width: 118px; +} + +/* Segmented control: two or three mutually exclusive modes. */ +.segmented { + display: inline-flex; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-sunken); + gap: 2px; +} + +.segmented button { + height: 26px; + padding: 0 14px; + border: 0; + border-radius: var(--border-radius-sm); + background: none; + color: var(--text-secondary); + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.segmented button:hover { + color: var(--text-primary); +} + +.segmented button[aria-pressed="true"] { + background: var(--bg-surface); + color: var(--text-primary); + font-weight: 500; + box-shadow: var(--shadow-card); +} + +.segmented.is-quiet { + background: transparent; +} + +/* ========== Buttons ========== */ +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--bg-surface); + color: var(--text-secondary); + cursor: pointer; + flex: none; + transition: border-color var(--transition-fast), color var(--transition-fast), + background-color var(--transition-fast); +} + +.icon-button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.icon-button:disabled { + color: var(--text-disabled); + cursor: default; +} + +.icon-button.is-on { + color: var(--status-warning); + border-color: var(--status-warning); + background: var(--status-warning-soft); +} + +.text-button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--bg-surface); + color: var(--text-secondary); + font-family: inherit; + font-size: 13px; + cursor: pointer; + flex: none; + transition: border-color var(--transition-fast), color var(--transition-fast); +} + +.text-button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.text-button:disabled { + color: var(--text-disabled); + cursor: default; +} + +.chip-button { + flex: none; + height: 30px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-surface); + color: var(--text-secondary); + font-family: inherit; + font-size: 13px; + cursor: pointer; +} + +.chip-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* ========== Content Scan Areas ========== */ +.list-scroll, +.settings-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; +} + +.settings-scroll { + gap: 16px; + max-width: 720px; +} + +.empty-state { + padding: 64px 20px; + text-align: center; + font-size: 14px; + color: var(--text-secondary); } -.row-actions { +.hint { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.hint-action { + margin-left: 8px; + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-family: inherit; + font-size: 13px; + cursor: pointer; +} + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.search-bar .el-input { + flex: 1; + min-width: 0; +} + +/* ========== Task Row ========== */ +.task-row { + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + flex: none; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-card); + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.task-row:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.task-row.is-selected { + border-color: var(--accent); + background: var(--accent-soft); +} + +.task-thumb { + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 62px; + border-radius: 4px; + background: var(--bg-sunken); + color: var(--text-disabled); + overflow: hidden; + flex: none; +} + +.task-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.task-body { + min-width: 0; +} + +.task-title { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-meta { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 4px 12px; + margin: 4px 0 0; + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.task-progress { + display: flex; align-items: center; + gap: 8px; + margin-top: 6px; +} + +.task-progress .el-progress { + flex: 1; + min-width: 60px; +} + +.progress-value { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.task-side { + display: flex; + align-items: center; + gap: 10px; + flex: none; +} + +.task-acts { + display: flex; + align-items: center; + gap: 6px; } .status-pill { @@ -295,6 +716,7 @@ body { font-size: 12px; font-weight: 500; white-space: nowrap; + flex: none; } .status-pill.is-done { @@ -312,74 +734,33 @@ body { background: var(--status-warning-soft); } -.state-cell { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 5px; -} - -.state-cell .el-progress { - width: 100%; -} - -.state-cell .progress-value { - font-size: 12px; - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -.detail-info { +/* Remote search result: thumbnail + copy + two clear actions. */ +.remote-row { display: grid; - grid-template-columns: 84px 1fr; - gap: 4px 12px; - padding: 10px 12px 12px 44px; - font-size: 13px; - line-height: 1.7; -} - -.detail-info dt { - color: var(--text-secondary); -} - -.detail-info dd { - margin: 0; - color: var(--text-primary); - word-break: break-all; -} - -.detail-actions { - grid-column: 1 / -1; - padding-top: 8px; -} - -/* ========== Toolbar under the list ========== */ -.list-toolbar { - flex: none; - display: flex; - flex-wrap: wrap; + grid-template-columns: 46px minmax(0, 1fr) auto; align-items: center; + gap: 12px; + flex: none; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-card); +} + +.pager-row { + display: flex; + justify-content: center; gap: 8px; - padding: 12px 0 16px; - margin-top: 12px; - border-top: 1px solid var(--border-color); -} - -.list-toolbar .toolbar-spacer { - flex: 1; - min-width: 8px; + padding: 12px 0 4px; } +/* ========== Pager ========== */ .pager { display: inline-flex; align-items: center; gap: 4px; } -.pager .el-button + .el-button { - margin-left: 0; -} - .pager-readout { display: inline-flex; align-items: center; @@ -396,7 +777,7 @@ body { button.page-current { min-width: 34px; - height: 24px; + height: 28px; padding: 0 6px; border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); @@ -419,63 +800,30 @@ button.page-current:hover { width: 56px; } -/* Icon-only row actions keep the operation column narrow. */ -.row-actions .el-button.is-circle, -.row-actions .el-button { - width: 32px; - height: 32px; - padding: 0; -} - -.row-actions .el-button + .el-button { - margin-left: 0; -} - -.row-actions .el-button.is-collected { - color: var(--status-warning); - border-color: var(--status-warning); - background: var(--status-warning-soft); -} - -/* ========== Main column ========== */ -.dashboard { +/* ========== Settings ========== */ +.settings-group { display: flex; flex-direction: column; - gap: 16px; - width: 100%; - max-width: 1100px; - margin: 0 auto; - padding: 20px; -} - -.dashboard-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; - align-items: start; -} - -.control-card { - background: var(--bg-card); + gap: 10px; + padding: 16px; border: 1px solid var(--border-color); border-radius: var(--border-radius); - box-shadow: var(--shadow-card); - padding: 16px; + background: var(--bg-card); } -.control-card .card-head { +.setting-row { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - margin-bottom: 12px; + gap: 16px; + font-size: 13px; + color: var(--text-primary); } -.control-card .card-row { - display: flex; - flex-wrap: wrap; - gap: 8px; +.setting-actions { + display: inline-flex; align-items: center; + gap: 8px; } .usage-line { @@ -486,7 +834,7 @@ button.page-current:hover { } .usage-line .usage-value { - font-size: 22px; + font-size: 26px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; @@ -497,90 +845,235 @@ button.page-current:hover { color: var(--text-secondary); } -.setting-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 13px; - color: var(--text-primary); -} - -.thumbnail-body { - display: flex; - flex-direction: column; - gap: 10px; -} - -.thumbnail-body .gallery-name { - margin: 0; - font-size: 13px; - color: var(--text-secondary); - word-break: break-all; -} - -.thumbnail-frame { - background: var(--bg-sunken); - border-radius: var(--border-radius-sm); - overflow: hidden; -} - -/* ========== Dialogs ========== */ -.detail-layout { - display: grid; - grid-template-columns: minmax(0, 200px) minmax(0, 1fr); - gap: 20px; - align-items: start; -} - -.detail-thumb { - width: 100%; - height: 200px; - border-radius: var(--border-radius-sm); - background: var(--bg-sunken); -} - -.detail-list { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - gap: 6px 12px; - margin: 0; - font-size: 13px; - line-height: 1.6; -} - -.detail-list dt { - color: var(--text-secondary); -} - -.detail-list dd { - margin: 0; - color: var(--text-primary); - word-break: break-word; -} - .mono-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; word-break: break-all; } -.config-form .section-title { - margin: 20px 0 12px; +/* ========== Detail Inspector ========== */ +.inspector { + flex: none; + width: var(--inspector-width); + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-surface); + border-left: 1px solid var(--border-color); } -.config-form .section-title:first-child { - margin-top: 0; +.inspector-bar { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); } -.config-form .el-form-item { - margin-bottom: 14px; +.inspector-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; } -.field-hint { - margin-left: 10px; +.preview-frame { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 3 / 4; + max-height: 320px; + border-radius: var(--border-radius-sm); + background: var(--bg-sunken); + border: 1px solid var(--border-color); + overflow: hidden; +} + +.preview-frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.preview-empty { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-disabled); +} + +.inspector-title { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.45; + color: var(--text-primary); + word-break: break-word; +} + +.inspector-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.inspector-badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + background: var(--bg-sunken); + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.progress-line { + display: flex; + align-items: center; + gap: 8px; +} + +.progress-line .el-progress { + flex: 1; +} + +.inspector-facts { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 6px 12px; + margin: 0; + padding-top: 4px; + border-top: 1px solid var(--border-color); + font-size: 13px; + line-height: 1.6; +} + +.inspector-facts dt { + color: var(--text-secondary); +} + +.inspector-facts dd { + margin: 0; + color: var(--text-primary); + word-break: break-word; +} + +.inspector-facts a { + color: var(--accent); + text-decoration: none; +} + +.inspector-facts a:hover { + text-decoration: underline; +} + +.inspector-label { + margin: 0; +} + +.resolution-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.resolution-list button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-surface); + color: var(--text-primary); + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.resolution-list button:hover { + border-color: var(--border-strong); +} + +.resolution-list button[aria-pressed="true"] { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.resolution-size { font-size: 12px; color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.resolution-list button[aria-pressed="true"] .resolution-size { + color: var(--accent); +} + +.inspector-actions { + flex: none; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 12px 16px 16px; + border-top: 1px solid var(--border-color); +} + +.inspector-actions.is-remote { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.inspector-actions .el-button { + margin-left: 0; + width: 100%; +} + +.inspector-actions .el-button.is-collected { + color: var(--status-warning); + border-color: var(--status-warning); + background: var(--status-warning-soft); +} + +.inspector-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + color: var(--text-disabled); + font-size: 13px; +} + +.inspector-empty p { + margin: 0; +} + +/* ========== Dialogs ========== */ +.el-dialog { + border-radius: 12px; +} + +.el-dialog__title { + font-size: 16px; + font-weight: 600; } /* ========== Element Plus harmonisation ========== */ @@ -606,28 +1099,3 @@ button.page-current:hover { .el-input__inner { font-size: 14px; } - -.el-table { - font-size: 14px; - --el-table-border-color: var(--border-color); - --el-table-header-bg-color: var(--bg-surface); - --el-table-row-hover-bg-color: var(--bg-hover); -} - -.el-table th.el-table__cell { - background-color: var(--bg-surface); - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.el-dialog { - border-radius: 12px; -} - -.el-dialog__title { - font-size: 16px; - font-weight: 600; -} diff --git a/src/utils/thumbnail.js b/src/utils/thumbnail.js new file mode 100644 index 0000000..73c5d0a --- /dev/null +++ b/src/utils/thumbnail.js @@ -0,0 +1,27 @@ +const BaseUrl = "https://downloader.lionwebsite.xyz/"; + +// Remote galleries carry a raw thumbnail path; it only resolves through the +// backend proxy once the active auth code is attached. +export function ehThumbnailUrl(path, AuthCode) { + if (!path) return ""; + return BaseUrl + "GalleryManage/ehThumbnail?" + new URLSearchParams({path, AuthCode}).toString(); +} + +const UNIT_SCALE = {b: 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4}; + +function sizeInBytes(text) { + const match = String(text).match(/([\d.]+)\s*([kmgt]?)(?:i?b)/i); + if (!match) return 0; + const scale = UNIT_SCALE[match[2].toLowerCase()] ?? 1; + return Number(match[1]) * scale; +} + +// Prefer the original file; otherwise take whichever entry reports the most bytes. +export function pickDefaultResolution(availableResolution) { + const entries = Object.entries(availableResolution || {}); + if (entries.length === 0) return ""; + const original = entries.find(([resolution]) => /original/i.test(resolution)); + if (original) return original[0]; + return entries.reduce((best, current) => + sizeInBytes(current[1]) > sizeInBytes(best[1]) ? current : best)[0]; +} diff --git a/tests/thumbnail.test.mjs b/tests/thumbnail.test.mjs new file mode 100644 index 0000000..53781d7 --- /dev/null +++ b/tests/thumbnail.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {pickDefaultResolution, ehThumbnailUrl} from '../src/utils/thumbnail.js' + +test('original resolution wins whenever it is offered', () => { + assert.equal(pickDefaultResolution({ + '800x': '37.53MiB', + '1280x': '66.57MiB', + '2560x': '84.01MiB', + 'Original': '498.2MiB' + }), 'Original') +}) + +test('without an original entry the largest file is chosen', () => { + // 1920x is bigger than 2560x here, so byte size decides, not the label. + assert.equal(pickDefaultResolution({ + '800x': '37.53MiB', + '1920x': '811.1MiB', + '2560x': '84.01MiB' + }), '1920x') +}) + +test('an empty or missing resolution map yields an empty choice', () => { + assert.equal(pickDefaultResolution({}), '') + assert.equal(pickDefaultResolution(undefined), '') +}) + +test('thumbnail urls carry the path and the active auth code', () => { + const url = new URL(ehThumbnailUrl('https://example.org/t?a=1&b=2', 'code with spaces')) + assert.equal(url.searchParams.get('path'), 'https://example.org/t?a=1&b=2') + assert.equal(url.searchParams.get('AuthCode'), 'code with spaces') + assert.equal(ehThumbnailUrl('', 'x'), '') +})