vitepress-plugin-filelist 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,27 @@
1
1
  # vitepress-plugin-filelist
2
2
 
3
- OpenList file browser for VitePress — tree view, batch ZIP download, folder password and token support.
3
+ 面向 VitePress 的 OpenList 文件浏览组件:树形展开、勾选批量打包、目录密码、token,直接调用 OpenList 接口。
4
4
 
5
- VitePress 的 OpenList 文件浏览组件:树形展开、勾选批量打包、目录密码、token,直接调用 OpenList 接口。
5
+ ## 功能
6
6
 
7
- ## Features
7
+ - 📁 树形目录,文件夹按需加载
8
+ - ✅ 勾选文件;选中文件夹会连带其已加载的子项
9
+ - 📦 批量打包 ZIP(基于 `fflate`),带进度
10
+ - ⬇️ 单文件下载与预览(`/d/`、`/p/`、`fs/get` 的 `raw_url`)
11
+ - 🔐 目录密码输入,可选只读 token
12
+ - 🔎 当前树过滤,按名称 / 大小 / 日期排序
13
+ - 🌐 使用 `/api/fs/list`、`/api/fs/get`、`/api/public/settings`
14
+ - 🧪 `base` 为空时使用内置演示数据
15
+ - SSR 安全(网络请求只在客户端执行)
8
16
 
9
- - 📁 Tree view with lazy-loaded folders
10
- - ✅ Checkbox selection, folder selects its loaded children
11
- - 📦 Batch download as ZIP (via `fflate`), with progress
12
- - ⬇️ Single file download and preview (`/d/`, `/p/`, `fs/get` raw URL)
13
- - 🔐 Folder password prompt, optional read-only token
14
- - 🔎 Filter current tree, sortable name / size / date
15
- - 🌐 Uses `/api/fs/list`, `/api/fs/get`, `/api/public/settings`
16
- - 🧪 Demo mode with built-in fake data when `base` is empty
17
- - SSR safe (network requests only run on the client)
18
-
19
- ## Install
17
+ ## 安装
20
18
 
21
19
  ```bash
22
20
  pnpm add -D vitepress-plugin-filelist
23
- # or: npm i -D vitepress-plugin-filelist
21
+ # 或:npm i -D vitepress-plugin-filelist
24
22
  ```
25
23
 
26
- ## Usage with vitepress-tuck
24
+ ## 在 vitepress-tuck 中使用
27
25
 
28
26
  ```ts
29
27
  // .vitepress/config.ts
@@ -53,7 +51,7 @@ export default {
53
51
  <FileList base="https://pan.example.com" path="/课程资料" />
54
52
  ```
55
53
 
56
- ## Usage with plain VitePress
54
+ ## 在普通 VitePress 中使用
57
55
 
58
56
  ```ts
59
57
  // .vitepress/theme/index.ts
@@ -69,27 +67,58 @@ export default {
69
67
  }
70
68
  ```
71
69
 
72
- ## Props
70
+ ## 组件属性
73
71
 
74
- | Prop | Type | Default | Description |
72
+ | 属性 | 类型 | 默认值 | 说明 |
75
73
  | --- | --- | --- | --- |
76
- | `base` | `string` | `''` | OpenList base URL. Empty = demo mode |
77
- | `path` | `string` | `'/'` | Initial directory |
78
- | `token` | `string` | `''` | Optional read-only token (guest when omitted) |
79
- | `password` | `string` | `''` | Folder password; can also be entered at runtime |
80
- | `title` | `string` | `''` | Toolbar title; falls back to OpenList `site_title` |
81
- | `locale` | `'zh-CN' \| 'en-US'` | `'zh-CN'` | Built-in label preset |
82
- | `labels` | `Partial<FileListLabels>` | `undefined` | Per-key label overrides |
74
+ | `base` | `string` | `''` | OpenList 站点地址;为空时进入演示模式 |
75
+ | `path` | `string` | `'/'` | 初始目录 |
76
+ | `sources` | `FileListSource[]` | `[]` | 多根挂载;设置后忽略 `path` |
77
+ | `expanded` | `boolean` | `false` | 目录根是否默认展开 |
78
+ | `token` | `string` | `''` | 可选只读 token;不传则以游客身份请求 |
79
+ | `password` | `string` | `''` | 目录密码,也可以在运行时输入 |
80
+ | `title` | `string` | `''` | 工具栏标题;为空时回退到 OpenList 的 `site_title` |
81
+ | `locale` | `'zh-CN' \| 'en-US'` | `'zh-CN'` | 内置文案预设 |
82
+ | `labels` | `Partial<FileListLabels>` | `undefined` | 按 key 覆盖单条文案 |
83
83
 
84
- Both `<FileList>` and `<VPFileList>` are registered.
84
+ 同时注册了 `<FileList>` 和 `<VPFileList>` 两个组件名。
85
85
 
86
- ## i18n
86
+ ## 多根挂载
87
+
88
+ ```md
89
+ <FileList
90
+ base="https://pan.example.com"
91
+ title="课程资料"
92
+ :sources="[
93
+ { path: '/课程资料/语文' },
94
+ { path: '/课程资料/数学', expanded: true },
95
+ { path: '/课程资料/说明.pdf' }
96
+ ]"
97
+ />
98
+ ```
99
+
100
+ - 目录路径挂载为根文件夹,默认折叠;`expanded` 可为全局或单个 source 打开
101
+ - 文件路径挂载为一条文件记录,直接预览/下载
102
+ - 根目录复选框与勾选、全选效果一致:选中根目录会选中其已加载的子项
103
+ - 每个 source 可单独设置 `title`、`password`
104
+ - 根节点顺序按 `sources` 顺序,子项仍按名称/大小/日期排序
105
+
106
+ ```ts
107
+ interface FileListSource {
108
+ path: string
109
+ title?: string
110
+ password?: string
111
+ expanded?: boolean
112
+ }
113
+ ```
114
+
115
+ ## 国际化
87
116
 
88
117
  ```md
89
118
  <FileList locale="en-US" base="https://pan.example.com" />
90
119
  ```
91
120
 
92
- Override individual labels on top of the preset:
121
+ 在预设基础上覆盖单条文案:
93
122
 
94
123
  ```md
95
124
  <FileList
@@ -98,74 +127,76 @@ Override individual labels on top of the preset:
98
127
  />
99
128
  ```
100
129
 
101
- Available presets: `zh-CN`, `en-US`. Placeholders use `{count}`, `{percent}`, `{message}`, `{name}` and are replaced by `formatLabel()`. The full key list is exported as `FileListLabels`:
130
+ 内置预设:`zh-CN`、`en-US`。占位符支持 `{count}`、`{percent}`、`{message}`、`{name}`,由 `formatLabel()` 替换。完整 key 列表通过 `FileListLabels` 导出:
102
131
 
103
132
  ```ts
104
133
  import type { FileListLabels } from 'vitepress-plugin-filelist/client'
105
134
  import { fileListLocales, formatLabel, resolveLabels } from 'vitepress-plugin-filelist/client'
106
135
  ```
107
136
 
108
- ## OpenList requirements
137
+ ## OpenList 要求
109
138
 
110
- 1. **Guest read access** — enable the guest user, or pass a read-only `token`.
111
- 2. **CORS** — OpenList's `cors.allow_origins` must include your site origin (default `*`).
112
- 3. **Same protocol** — an HTTPS page cannot call an HTTP OpenList (mixed content).
113
- 4. **Token is public** — only ship a read-only token that can be revoked.
139
+ 1. **游客只读权限**:开启游客用户,或传入只读 `token`
140
+ 2. **CORS**:OpenList 的 `cors.allow_origins` 必须包含站点来源(默认 `*`)
141
+ 3. **同协议**:HTTPS 页面不能请求 HTTP 的 OpenList(混合内容)
142
+ 4. **token 是公开的**:只放可撤销的只读 token,不要放管理 token
114
143
 
115
- The component calls:
144
+ 组件会调用:
116
145
 
117
- | Endpoint | Purpose |
146
+ | 接口 | 用途 |
118
147
  | --- | --- |
119
- | `POST /api/fs/list` | Directory listing (`per_page: 0`) |
120
- | `POST /api/fs/get` | `raw_url` for download / preview / ZIP; falls back to `/d/...?sign=` |
121
- | `GET /api/public/settings` | `site_title` fallback |
122
- | `GET /d/...` `GET /p/...` | Download / preview links generated by OpenList |
148
+ | `POST /api/fs/list` | 目录列表(`per_page: 0`) |
149
+ | `POST /api/fs/get` | 获取下载 / 预览 / 打包用的 `raw_url`;失败时回退到 `/d/...?sign=` |
150
+ | `GET /api/public/settings` | 获取 `site_title` |
151
+ | `GET /d/...`、`GET /p/...` | OpenList 生成的下载 / 预览链接 |
123
152
 
124
- ## Exports
153
+ ## 导出
125
154
 
126
- | Entry | Contents |
155
+ | 入口 | 内容 |
127
156
  | --- | --- |
128
- | `vitepress-plugin-filelist` | `fileList` plugin (default export) |
129
- | `vitepress-plugin-filelist/client` | `FileList`, `VPFileList`, `enhanceAppWithFileList`, types |
130
- | `vitepress-plugin-filelist/style.css` | Component styles |
157
+ | `vitepress-plugin-filelist` | `fileList` 插件(默认导出) |
158
+ | `vitepress-plugin-filelist/client` | `FileList`、`VPFileList`、`enhanceAppWithFileList`、类型 |
159
+ | `vitepress-plugin-filelist/style.css` | 组件样式 |
131
160
 
132
- ## Compatibility
161
+ ## 兼容性
133
162
 
134
- Peer dependency allows VitePress 1.x and 2.0 alpha: `^1.0.0 || ^2.0.0-alpha.0`.
163
+ peerDependencies 同时支持 VitePress 1.x 与 2.0 alpha:`^1.0.0 || ^2.0.0-alpha.0`。
135
164
 
136
- Tested locally against VitePress `2.0.0-alpha.21` on Node 22:
165
+ 在 Node 22 下针对 VitePress `2.0.0-alpha.21` 本地实测:
137
166
 
138
- | Package manager | Install | Build |
167
+ | 包管理器 | 安装 | 构建 |
139
168
  | --- | --- | --- |
140
169
  | npm 10 | ✅ | ✅ |
141
170
  | pnpm 11.25 | ✅ | ✅ |
142
- | yarn 1.22 (classic) | ✅ | ✅ |
171
+ | yarn 1.22(classic) | ✅ | ✅ |
143
172
  | bun 1.4 | ✅ | ✅ |
144
- | deno 2.9 | ✅ `deno install` | ❌ fails while Vite/rolldown loads the config |
173
+ | deno 2.9 | ✅ `deno install` | ❌ Vite/rolldown 加载配置时失败 |
145
174
 
146
- Deno can install the npm dependency, but building a VitePress site with Deno currently fails inside rolldown (`stream did not contain valid UTF-8`). Use Node for builds.
175
+ Deno 可以安装 npm 依赖,但目前用 Deno 构建 VitePress 会在 rolldown 里报 `stream did not contain valid UTF-8`;构建请使用 Node。
147
176
 
148
- ## Release
177
+ ## 发布
149
178
 
150
- Published to npm from GitHub Actions via Trusted Publishing (OIDC) with provenance.
179
+ 通过 GitHub Actions 的 Trusted Publishing(OIDC)发布到 npm,带 provenance。
151
180
 
152
- ## Notes
181
+ ## 说明
153
182
 
154
- - Batch ZIP downloads files through `raw_url` (OpenList `/p/` proxy), which keeps CORS on the OpenList origin.
155
- - Select-all only counts already-loaded files; the footer reports how many folders are still collapsed.
156
- - Directory listings are requested with `per_page: 0` (all entries). Very large folders may be slow; pagination is not implemented yet.
183
+ - 批量 ZIP 通过 `raw_url`(OpenList `/p/` 代理)下载,CORS 保持在 OpenList 源上
184
+ - 全选只统计已加载的文件;页脚会显示还有多少文件夹未展开
185
+ - 目录以 `per_page: 0` 请求全部条目;超大目录可能较慢,暂未实现分页
157
186
 
158
- ## Development
187
+ ## 开发
159
188
 
160
189
  ```bash
161
- npm install
162
- npm run build # dist/node, dist/client/ssr, dist/client/browser, style.css
163
- npm run typecheck
164
- npm test # Vitest: utils + locale helpers
165
- npm run pack:check
190
+ pnpm install
191
+ pnpm run build # 生成 dist/node、dist/client/ssr、dist/client/browser、style.css
192
+ pnpm run typecheck
193
+ pnpm test # Vitest:utils + locale helpers
194
+ pnpm run pack:check
166
195
  ```
167
196
 
168
- The demo site in `demo/` installs the package through `file:..`:
197
+ 仓库统一使用 pnpm(`pnpm-lock.yaml`),CI 与发布流程执行 `pnpm install --frozen-lockfile`。
198
+
199
+ `demo/` 下的演示站通过 `file:..` 安装本地包:
169
200
 
170
201
  ```bash
171
202
  cd demo
@@ -34,11 +34,25 @@ interface FileListLabels {
34
34
  batchFailed: string;
35
35
  defaultZip: string;
36
36
  }
37
+ interface FileListSource {
38
+ /** OpenList path, directory or single file. */
39
+ path: string;
40
+ /** Root label. Defaults to the last path segment. */
41
+ title?: string;
42
+ /** Per-source folder password. */
43
+ password?: string;
44
+ /** Initial expanded state for a directory source. Overrides the global `expanded` prop. */
45
+ expanded?: boolean;
46
+ }
37
47
  interface FileListProps {
38
48
  /** OpenList base URL, e.g. `https://pan.example.com`. Empty string enables demo mode. */
39
49
  base?: string;
40
50
  /** Initial directory, defaults to `/`. */
41
51
  path?: string;
52
+ /** Mount multiple paths as root nodes. When set, `path` is ignored. */
53
+ sources?: FileListSource[];
54
+ /** Expand directory roots initially. Defaults to `false`. */
55
+ expanded?: boolean;
42
56
  /** Optional read-only token. Guest access is used when omitted. */
43
57
  token?: string;
44
58
  /** Folder password. The component also asks at runtime when OpenList reports one. */
@@ -64,6 +78,8 @@ interface OpenListEntry {
64
78
  declare const _default: import("vue").DefineComponent<FileListProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<FileListProps> & Readonly<{}>, {
65
79
  base: string;
66
80
  path: string;
81
+ sources: FileListSource[];
82
+ expanded: boolean;
67
83
  token: string;
68
84
  password: string;
69
85
  title: string;
@@ -176,4 +192,4 @@ export declare function compareNodes(a: SortableNode, b: SortableNode, key: Sort
176
192
  * ```
177
193
  */
178
194
  export declare function enhanceAppWithFileList({ app }: EnhanceAppContext): void;
179
- export { _default as FileList, _default as VPFileList, type FileListLabels, type FileListLocale, type FileListProps, type OpenListEntry };
195
+ export { _default as FileList, _default as VPFileList, type FileListLabels, type FileListLocale, type FileListProps, type FileListSource, type OpenListEntry };
@@ -173,32 +173,33 @@ const _hoisted_23 = [
173
173
  ".indeterminate",
174
174
  "onChange"
175
175
  ];
176
- const _hoisted_24 = ["onClick"];
177
- const _hoisted_25 = {
176
+ const _hoisted_24 = ["title"];
177
+ const _hoisted_25 = ["onClick"];
178
+ const _hoisted_26 = {
178
179
  key: 1,
179
180
  class: "fl-arrow-placeholder"
180
181
  };
181
- const _hoisted_26 = ["onClick"];
182
- const _hoisted_27 = {
182
+ const _hoisted_27 = ["onClick"];
183
+ const _hoisted_28 = {
183
184
  key: 0,
184
185
  class: "fl-folder-icon"
185
186
  };
186
- const _hoisted_28 = {
187
+ const _hoisted_29 = {
187
188
  key: 1,
188
189
  class: "fl-ext-badge"
189
190
  };
190
- const _hoisted_29 = {
191
+ const _hoisted_30 = {
191
192
  key: 2,
192
193
  class: "fl-loading"
193
194
  };
194
- const _hoisted_30 = { class: "fl-cell fl-cell-size" };
195
- const _hoisted_31 = { class: "fl-cell fl-cell-date" };
196
- const _hoisted_32 = { class: "fl-cell fl-cell-op" };
197
- const _hoisted_33 = ["title", "onClick"];
195
+ const _hoisted_31 = { class: "fl-cell fl-cell-size" };
196
+ const _hoisted_32 = { class: "fl-cell fl-cell-date" };
197
+ const _hoisted_33 = { class: "fl-cell fl-cell-op" };
198
198
  const _hoisted_34 = ["title", "onClick"];
199
- const _hoisted_35 = { class: "fl-foot" };
200
- const _hoisted_36 = { key: 0 };
201
- const _hoisted_37 = {
199
+ const _hoisted_35 = ["title", "onClick"];
200
+ const _hoisted_36 = { class: "fl-foot" };
201
+ const _hoisted_37 = { key: 0 };
202
+ const _hoisted_38 = {
202
203
  key: 4,
203
204
  class: "fl-toast"
204
205
  };
@@ -207,6 +208,11 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
207
208
  props: {
208
209
  base: { default: "" },
209
210
  path: { default: "/" },
211
+ sources: { default: () => [] },
212
+ expanded: {
213
+ type: Boolean,
214
+ default: false
215
+ },
210
216
  token: { default: "" },
211
217
  password: { default: "" },
212
218
  title: { default: "" },
@@ -409,14 +415,14 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
409
415
  if (payload.code !== 200) throw new ApiError(payload.message || `HTTP ${response.status}`, payload.code ?? response.status);
410
416
  return payload.data;
411
417
  }
412
- async function listDir(path) {
418
+ async function listDir(path, password = activePassword.value) {
413
419
  if (demo.value) {
414
420
  await new Promise((resolve) => setTimeout(resolve, 220));
415
421
  return DEMO_TREE[path] ?? [];
416
422
  }
417
423
  return (await request("/api/fs/list", {
418
424
  path,
419
- password: activePassword.value,
425
+ password,
420
426
  page: 1,
421
427
  per_page: 0,
422
428
  refresh: false
@@ -424,13 +430,14 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
424
430
  }
425
431
  async function resolveUrl(node) {
426
432
  if (demo.value) return "";
433
+ if (node.raw_url) return node.raw_url;
427
434
  const cached = downloadCache.get(node.path);
428
435
  if (cached) return cached;
429
436
  let url = "";
430
437
  try {
431
438
  url = (await request("/api/fs/get", {
432
439
  path: node.path,
433
- password: activePassword.value
440
+ password: node.password || activePassword.value
434
441
  }))?.raw_url ?? "";
435
442
  } catch {
436
443
  url = "";
@@ -455,7 +462,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
455
462
  nodes.sort((a, b) => compareNodes(a, b, sortKey.value, sortAsc.value));
456
463
  for (const node of nodes) if (node.children.length) sortTree(node.children);
457
464
  }
458
- function toNodes(items, parent, depth) {
465
+ function toNodes(items, parent, depth, password = "") {
459
466
  return items.map((item) => ({
460
467
  ...item,
461
468
  path: joinPath(parent, item.name),
@@ -463,7 +470,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
463
470
  expanded: false,
464
471
  loaded: false,
465
472
  loading: false,
466
- children: []
473
+ children: [],
474
+ password
467
475
  }));
468
476
  }
469
477
  function setSort(key) {
@@ -472,15 +480,82 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
472
480
  sortKey.value = key;
473
481
  sortAsc.value = true;
474
482
  }
475
- sortTree(roots.value);
483
+ if (props.sources.length) {
484
+ for (const root of roots.value) if (root.children.length) sortTree(root.children);
485
+ } else sortTree(roots.value);
486
+ }
487
+ function sourceName(path) {
488
+ const parts = path.split("/").filter(Boolean);
489
+ return parts[parts.length - 1] || path;
490
+ }
491
+ async function loadChildren(node) {
492
+ if (node.loaded) return true;
493
+ node.loading = true;
494
+ try {
495
+ node.children = toNodes(await listDir(node.path, node.password), node.path, node.depth + 1, node.password);
496
+ sortTree(node.children);
497
+ node.loaded = true;
498
+ return true;
499
+ } catch (error) {
500
+ showToast(t("folderFailed", { message: errorText(error) }));
501
+ return false;
502
+ } finally {
503
+ node.loading = false;
504
+ }
505
+ }
506
+ async function loadSourceRoot(source) {
507
+ const path = source.path;
508
+ const password = source.password ?? "";
509
+ let item = {
510
+ name: sourceName(path),
511
+ size: 0,
512
+ is_dir: true,
513
+ modified: ""
514
+ };
515
+ if (demo.value) {
516
+ if (/\/[^/]+\.[^/]+$/.test(path)) item = {
517
+ ...item,
518
+ is_dir: false,
519
+ modified: "2026-09-01T08:00:00+08:00"
520
+ };
521
+ } else try {
522
+ const data = await request("/api/fs/get", {
523
+ path,
524
+ password
525
+ });
526
+ item = {
527
+ ...item,
528
+ ...data
529
+ };
530
+ } catch {}
531
+ return {
532
+ ...item,
533
+ name: source.title || item.name || sourceName(path),
534
+ path,
535
+ depth: 0,
536
+ expanded: source.expanded ?? props.expanded,
537
+ loaded: false,
538
+ loading: false,
539
+ children: [],
540
+ password,
541
+ isRoot: true
542
+ };
476
543
  }
477
544
  async function loadRoot() {
478
545
  loadingRoot.value = true;
479
546
  errorMsg.value = "";
480
547
  needsPassword.value = false;
481
548
  try {
549
+ if (props.sources.length) {
550
+ const nodes = await Promise.all(props.sources.map((source) => loadSourceRoot(source)));
551
+ roots.value = nodes;
552
+ for (const node of roots.value) if (node.is_dir && node.expanded) node.expanded = await loadChildren(node);
553
+ selected.value = /* @__PURE__ */ new Set();
554
+ downloadCache.clear();
555
+ return;
556
+ }
482
557
  const items = await listDir(props.path);
483
- roots.value = toNodes(items, props.path, 0);
558
+ roots.value = toNodes(items, props.path, 0, activePassword.value);
484
559
  sortTree(roots.value);
485
560
  selected.value = /* @__PURE__ */ new Set();
486
561
  downloadCache.clear();
@@ -498,19 +573,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
498
573
  node.expanded = false;
499
574
  return;
500
575
  }
501
- if (!node.loaded) {
502
- node.loading = true;
503
- try {
504
- node.children = toNodes(await listDir(node.path), node.path, node.depth + 1);
505
- sortTree(node.children);
506
- node.loaded = true;
507
- } catch (error) {
508
- showToast(t("folderFailed", { message: errorText(error) }));
509
- return;
510
- } finally {
511
- node.loading = false;
512
- }
513
- }
576
+ if (!await loadChildren(node)) return;
514
577
  node.expanded = true;
515
578
  }
516
579
  const visible = computed(() => {
@@ -735,6 +798,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
735
798
  key: node.path,
736
799
  class: normalizeClass(["fl-row", {
737
800
  "fl-row-dir": node.is_dir,
801
+ "fl-row-root": node.isRoot,
738
802
  "fl-row-selected": selected.value.has(node.path)
739
803
  }])
740
804
  }, [
@@ -746,23 +810,24 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
746
810
  }, null, 40, _hoisted_23)]),
747
811
  createElementVNode("span", {
748
812
  class: "fl-cell fl-cell-name",
813
+ title: node.isRoot ? node.path : void 0,
749
814
  style: normalizeStyle({ paddingLeft: `${node.depth * 22}px` })
750
815
  }, [node.is_dir ? (openBlock(), createElementBlock("button", {
751
816
  key: 0,
752
817
  class: normalizeClass(["fl-arrow", { "fl-arrow-open": node.expanded }]),
753
818
  type: "button",
754
819
  onClick: ($event) => toggleExpand(node)
755
- }, "▸", 10, _hoisted_24)) : (openBlock(), createElementBlock("span", _hoisted_25)), createElementVNode("span", {
820
+ }, "▸", 10, _hoisted_25)) : (openBlock(), createElementBlock("span", _hoisted_26)), createElementVNode("span", {
756
821
  class: normalizeClass(["fl-name", { "fl-name-link": node.is_dir }]),
757
822
  onClick: ($event) => node.is_dir ? toggleExpand(node) : download(node)
758
823
  }, [
759
- node.is_dir ? (openBlock(), createElementBlock("span", _hoisted_27, "📁")) : (openBlock(), createElementBlock("span", _hoisted_28, toDisplayString(unref(extOf)(node.name) || "FILE"), 1)),
824
+ node.is_dir ? (openBlock(), createElementBlock("span", _hoisted_28, "📁")) : (openBlock(), createElementBlock("span", _hoisted_29, toDisplayString(unref(extOf)(node.name) || "FILE"), 1)),
760
825
  createTextVNode(" " + toDisplayString(node.name) + " ", 1),
761
- node.loading ? (openBlock(), createElementBlock("span", _hoisted_29, "…")) : createCommentVNode("v-if", true)
762
- ], 10, _hoisted_26)], 4),
763
- createElementVNode("span", _hoisted_30, toDisplayString(node.is_dir ? "-" : unref(formatSize)(node.size)), 1),
764
- createElementVNode("span", _hoisted_31, toDisplayString(unref(formatDate)(node.modified)), 1),
765
- createElementVNode("span", _hoisted_32, [!node.is_dir ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [canPreview(node) ? (openBlock(), createElementBlock("button", {
826
+ node.loading ? (openBlock(), createElementBlock("span", _hoisted_30, "…")) : createCommentVNode("v-if", true)
827
+ ], 10, _hoisted_27)], 12, _hoisted_24),
828
+ createElementVNode("span", _hoisted_31, toDisplayString(node.is_dir ? "-" : unref(formatSize)(node.size)), 1),
829
+ createElementVNode("span", _hoisted_32, toDisplayString(unref(formatDate)(node.modified)), 1),
830
+ createElementVNode("span", _hoisted_33, [!node.is_dir ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [canPreview(node) ? (openBlock(), createElementBlock("button", {
766
831
  key: 0,
767
832
  class: "fl-download",
768
833
  type: "button",
@@ -782,7 +847,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
782
847
  cx: "12",
783
848
  cy: "12",
784
849
  r: "3"
785
- })], -1)])], 8, _hoisted_33)) : createCommentVNode("v-if", true), createElementVNode("button", {
850
+ })], -1)])], 8, _hoisted_34)) : createCommentVNode("v-if", true), createElementVNode("button", {
786
851
  class: "fl-download",
787
852
  type: "button",
788
853
  title: t("download"),
@@ -801,11 +866,11 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
801
866
  createElementVNode("path", { d: "M12 3v12" }),
802
867
  createElementVNode("path", { d: "m7 10 5 5 5-5" }),
803
868
  createElementVNode("path", { d: "M5 21h14" })
804
- ], -1)])], 8, _hoisted_34)], 64)) : createCommentVNode("v-if", true)])
869
+ ], -1)])], 8, _hoisted_35)], 64)) : createCommentVNode("v-if", true)])
805
870
  ], 2);
806
871
  }), 128)),
807
- createElementVNode("div", _hoisted_35, [createElementVNode("span", null, toDisplayString(t("loaded", { count: allFiles.value.length })) + " · " + toDisplayString(t("selected", { count: selected.value.size })), 1), unloadedFolders.value ? (openBlock(), createElementBlock("span", _hoisted_36, " · " + toDisplayString(t("unloaded", { count: unloadedFolders.value })), 1)) : createCommentVNode("v-if", true)]),
808
- toast.value ? (openBlock(), createElementBlock("div", _hoisted_37, toDisplayString(toast.value), 1)) : createCommentVNode("v-if", true)
872
+ createElementVNode("div", _hoisted_36, [createElementVNode("span", null, toDisplayString(t("loaded", { count: allFiles.value.length })) + " · " + toDisplayString(t("selected", { count: selected.value.size })), 1), unloadedFolders.value ? (openBlock(), createElementBlock("span", _hoisted_37, " · " + toDisplayString(t("unloaded", { count: unloadedFolders.value })), 1)) : createCommentVNode("v-if", true)]),
873
+ toast.value ? (openBlock(), createElementBlock("div", _hoisted_38, toDisplayString(toast.value), 1)) : createCommentVNode("v-if", true)
809
874
  ]);
810
875
  };
811
876
  }
@@ -34,11 +34,25 @@ interface FileListLabels {
34
34
  batchFailed: string;
35
35
  defaultZip: string;
36
36
  }
37
+ interface FileListSource {
38
+ /** OpenList path, directory or single file. */
39
+ path: string;
40
+ /** Root label. Defaults to the last path segment. */
41
+ title?: string;
42
+ /** Per-source folder password. */
43
+ password?: string;
44
+ /** Initial expanded state for a directory source. Overrides the global `expanded` prop. */
45
+ expanded?: boolean;
46
+ }
37
47
  interface FileListProps {
38
48
  /** OpenList base URL, e.g. `https://pan.example.com`. Empty string enables demo mode. */
39
49
  base?: string;
40
50
  /** Initial directory, defaults to `/`. */
41
51
  path?: string;
52
+ /** Mount multiple paths as root nodes. When set, `path` is ignored. */
53
+ sources?: FileListSource[];
54
+ /** Expand directory roots initially. Defaults to `false`. */
55
+ expanded?: boolean;
42
56
  /** Optional read-only token. Guest access is used when omitted. */
43
57
  token?: string;
44
58
  /** Folder password. The component also asks at runtime when OpenList reports one. */
@@ -64,6 +78,8 @@ interface OpenListEntry {
64
78
  declare const _default: import("vue").DefineComponent<FileListProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<FileListProps> & Readonly<{}>, {
65
79
  base: string;
66
80
  path: string;
81
+ sources: FileListSource[];
82
+ expanded: boolean;
67
83
  token: string;
68
84
  password: string;
69
85
  title: string;
@@ -176,4 +192,4 @@ export declare function compareNodes(a: SortableNode, b: SortableNode, key: Sort
176
192
  * ```
177
193
  */
178
194
  export declare function enhanceAppWithFileList({ app }: EnhanceAppContext): void;
179
- export { _default as FileList, _default as VPFileList, type FileListLabels, type FileListLocale, type FileListProps, type OpenListEntry };
195
+ export { _default as FileList, _default as VPFileList, type FileListLabels, type FileListLocale, type FileListProps, type FileListSource, type OpenListEntry };
@@ -131,6 +131,11 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
131
131
  props: {
132
132
  base: { default: "" },
133
133
  path: { default: "/" },
134
+ sources: { default: () => [] },
135
+ expanded: {
136
+ type: Boolean,
137
+ default: false
138
+ },
134
139
  token: { default: "" },
135
140
  password: { default: "" },
136
141
  title: { default: "" },
@@ -167,6 +172,12 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
167
172
  const batchBusy = ref(false);
168
173
  const batchProgress = ref(0);
169
174
  const downloadCache = /* @__PURE__ */ new Map();
175
+ let toastTimer;
176
+ function showToast(msg) {
177
+ toast.value = msg;
178
+ clearTimeout(toastTimer);
179
+ toastTimer = setTimeout(() => toast.value = "", 3200);
180
+ }
170
181
  function errorText(error) {
171
182
  return error instanceof Error ? error.message : String(error);
172
183
  }
@@ -327,14 +338,14 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
327
338
  if (payload.code !== 200) throw new ApiError(payload.message || `HTTP ${response.status}`, payload.code ?? response.status);
328
339
  return payload.data;
329
340
  }
330
- async function listDir(path) {
341
+ async function listDir(path, password = activePassword.value) {
331
342
  if (demo.value) {
332
343
  await new Promise((resolve) => setTimeout(resolve, 220));
333
344
  return DEMO_TREE[path] ?? [];
334
345
  }
335
346
  return (await request("/api/fs/list", {
336
347
  path,
337
- password: activePassword.value,
348
+ password,
338
349
  page: 1,
339
350
  per_page: 0,
340
351
  refresh: false
@@ -353,7 +364,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
353
364
  nodes.sort((a, b) => compareNodes(a, b, sortKey.value, sortAsc.value));
354
365
  for (const node of nodes) if (node.children.length) sortTree(node.children);
355
366
  }
356
- function toNodes(items, parent, depth) {
367
+ function toNodes(items, parent, depth, password = "") {
357
368
  return items.map((item) => ({
358
369
  ...item,
359
370
  path: joinPath(parent, item.name),
@@ -361,16 +372,82 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
361
372
  expanded: false,
362
373
  loaded: false,
363
374
  loading: false,
364
- children: []
375
+ children: [],
376
+ password
365
377
  }));
366
378
  }
379
+ function sourceName(path) {
380
+ const parts = path.split("/").filter(Boolean);
381
+ return parts[parts.length - 1] || path;
382
+ }
383
+ async function loadChildren(node) {
384
+ if (node.loaded) return true;
385
+ node.loading = true;
386
+ try {
387
+ node.children = toNodes(await listDir(node.path, node.password), node.path, node.depth + 1, node.password);
388
+ sortTree(node.children);
389
+ node.loaded = true;
390
+ return true;
391
+ } catch (error) {
392
+ showToast(t("folderFailed", { message: errorText(error) }));
393
+ return false;
394
+ } finally {
395
+ node.loading = false;
396
+ }
397
+ }
398
+ async function loadSourceRoot(source) {
399
+ const path = source.path;
400
+ const password = source.password ?? "";
401
+ let item = {
402
+ name: sourceName(path),
403
+ size: 0,
404
+ is_dir: true,
405
+ modified: ""
406
+ };
407
+ if (demo.value) {
408
+ if (/\/[^/]+\.[^/]+$/.test(path)) item = {
409
+ ...item,
410
+ is_dir: false,
411
+ modified: "2026-09-01T08:00:00+08:00"
412
+ };
413
+ } else try {
414
+ const data = await request("/api/fs/get", {
415
+ path,
416
+ password
417
+ });
418
+ item = {
419
+ ...item,
420
+ ...data
421
+ };
422
+ } catch {}
423
+ return {
424
+ ...item,
425
+ name: source.title || item.name || sourceName(path),
426
+ path,
427
+ depth: 0,
428
+ expanded: source.expanded ?? props.expanded,
429
+ loaded: false,
430
+ loading: false,
431
+ children: [],
432
+ password,
433
+ isRoot: true
434
+ };
435
+ }
367
436
  async function loadRoot() {
368
437
  loadingRoot.value = true;
369
438
  errorMsg.value = "";
370
439
  needsPassword.value = false;
371
440
  try {
441
+ if (props.sources.length) {
442
+ const nodes = await Promise.all(props.sources.map((source) => loadSourceRoot(source)));
443
+ roots.value = nodes;
444
+ for (const node of roots.value) if (node.is_dir && node.expanded) node.expanded = await loadChildren(node);
445
+ selected.value = /* @__PURE__ */ new Set();
446
+ downloadCache.clear();
447
+ return;
448
+ }
372
449
  const items = await listDir(props.path);
373
- roots.value = toNodes(items, props.path, 0);
450
+ roots.value = toNodes(items, props.path, 0, activePassword.value);
374
451
  sortTree(roots.value);
375
452
  selected.value = /* @__PURE__ */ new Set();
376
453
  downloadCache.clear();
@@ -454,8 +531,9 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
454
531
  ssrRenderList(visible.value, (node) => {
455
532
  _push(`<div class="${ssrRenderClass([{
456
533
  "fl-row-dir": node.is_dir,
534
+ "fl-row-root": node.isRoot,
457
535
  "fl-row-selected": selected.value.has(node.path)
458
- }, "fl-row"])}"><span class="fl-cell fl-cell-check"><input type="checkbox"${ssrIncludeBooleanAttr(isChecked(node)) ? " checked" : ""}${ssrRenderAttr("indeterminate", isIndeterminate(node))}></span><span class="fl-cell fl-cell-name" style="${ssrRenderStyle({ paddingLeft: `${node.depth * 22}px` })}">`);
536
+ }, "fl-row"])}"><span class="fl-cell fl-cell-check"><input type="checkbox"${ssrIncludeBooleanAttr(isChecked(node)) ? " checked" : ""}${ssrRenderAttr("indeterminate", isIndeterminate(node))}></span><span class="fl-cell fl-cell-name"${ssrRenderAttr("title", node.isRoot ? node.path : void 0)} style="${ssrRenderStyle({ paddingLeft: `${node.depth * 22}px` })}">`);
459
537
  if (node.is_dir) _push(`<button type="button" class="${ssrRenderClass([{ "fl-arrow-open": node.expanded }, "fl-arrow"])}">▸</button>`);
460
538
  else _push(`<span class="fl-arrow-placeholder"></span>`);
461
539
  _push(`<span class="${ssrRenderClass([{ "fl-name-link": node.is_dir }, "fl-name"])}">`);
@@ -125,6 +125,10 @@
125
125
  background: var(--vp-c-bg-soft);
126
126
  }
127
127
 
128
+ .fl-row-root .fl-name {
129
+ font-weight: 600;
130
+ }
131
+
128
132
  .fl-row-selected {
129
133
  background: var(--vp-c-brand-soft);
130
134
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "vitepress-plugin-filelist",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
+ "packageManager": "pnpm@11.25.0",
4
5
  "type": "module",
5
6
  "description": "OpenList file browser for VitePress: tree view, batch ZIP download, i18n, password and token support.",
6
7
  "author": "nrjycyd",