+
-
分享“{{ basename(path) }}”
+ 分享“{{ title }}”
diff --git a/web/src/app/SharePanel.vue b/web/src/app/SharePanel.vue
index aecf09a..378e4e7 100644
--- a/web/src/app/SharePanel.vue
+++ b/web/src/app/SharePanel.vue
@@ -6,7 +6,7 @@ import Icon from './Icon.vue'
import { toast } from './workspace'
// path 为空时列出全部分享,否则管理此文件或文件夹的分享。
-const props = defineProps<{ path: string | null }>()
+const props = defineProps<{ path: string | string[] | null }>()
const EXPIRY_OPTIONS = [
{ label: '永久有效', seconds: 0 },
@@ -31,7 +31,9 @@ function errorText(err: unknown) {
async function load() {
try {
- shares.value = await api.shares(props.path ?? undefined)
+ const selected = props.path
+ const all = await api.shares(typeof selected === 'string' ? selected : undefined)
+ shares.value = Array.isArray(selected) ? all.filter((share) => share.items.length === selected.length && share.items.every((item) => selected.includes(item.path))) : all
} catch {
failed.value = true
}
@@ -77,20 +79,21 @@ onMounted(load)
-
分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。
+
分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。多选生成一个集合链接,部分主体删除不影响其他项。
读取分享失败
{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}
diff --git a/web/src/app/api.ts b/web/src/app/api.ts
index 7dbb6e3..4a64ff6 100644
--- a/web/src/app/api.ts
+++ b/web/src/app/api.ts
@@ -44,8 +44,9 @@ export interface SearchResult {
}
export interface Share {
+ items: { id: number; path: string; exists: boolean }[]
token: string
- path: string
+ path?: string
createdAt: number
// 0 为永久
expiresAt: number
@@ -188,8 +189,8 @@ export const api = {
shares: async (path?: string) =>
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
// expiresIn 为有效时长(秒),0 为永久
- createShare: (path: string, password: string, expiresIn: number) =>
- json
(request('POST', '/api/v1/shares', { json: { path, password, expiresIn } })),
+ createShare: (path: string | string[], password: string, expiresIn: number) =>
+ json(request('POST', '/api/v1/shares', { json: { ...(typeof path === 'string' ? { path } : { paths: path }), password, expiresIn } })),
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index b17fbfb..c1abf8e 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -61,7 +61,7 @@ export const ws = reactive({
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
// 分享当前笔记的对话框(笔记路径)
- sharing: null as string | null,
+ sharing: null as string | string[] | null,
// 「移动到…」对话框(笔记或文件夹路径)
moving: null as string | string[] | null,
// 导入目标目录,空字符串为根目录
diff --git a/web/src/share/main.ts b/web/src/share/main.ts
index 437efeb..3826605 100644
--- a/web/src/share/main.ts
+++ b/web/src/share/main.ts
@@ -24,13 +24,30 @@ interface SharedFile {
dir: false
}
+interface SharedCollection {
+ collection: true
+ title: string
+ items: { id: number; title: string; exists: boolean; dir: boolean }[]
+}
+
const root = document.getElementById('share')!
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
const api = '/api/v1/public/shares/' + encodeURIComponent(token)
+const selectedItem = new URL(location.href).searchParams.get('item')
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
-const pageUrl = (path: string) => location.pathname + (path ? '?path=' + encodeURIComponent(path) : '')
-const downloadUrl = () => api + '/assets/' + encodePath(selectedPath) + '?download=1'
+function pageUrl(path: string, item: string | null = selectedItem) {
+ const query = new URLSearchParams()
+ if (item !== null) query.set('item', item)
+ if (path) query.set('path', path)
+ return location.pathname + (query.size ? '?' + query : '')
+}
+function downloadUrl() {
+ const url = new URL(api + '/assets/' + encodePath(selectedPath), location.origin)
+ url.searchParams.set('download', '1')
+ if (selectedItem !== null) url.searchParams.set('item', selectedItem)
+ return url.href
+}
function message(text: string) {
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
@@ -39,13 +56,16 @@ function message(text: string) {
async function load() {
let res: Response
try {
- res = await fetch(api + (selectedPath ? '?path=' + encodeURIComponent(selectedPath) : ''), { credentials: 'same-origin', cache: 'no-store' })
+ const query = new URLSearchParams()
+ if (selectedPath) query.set('path', selectedPath)
+ if (selectedItem !== null) query.set('item', selectedItem)
+ res = await fetch(api + (query.size ? '?' + query : ''), { credentials: 'same-origin', cache: 'no-store' })
} catch {
message('网络错误,请检查连接后刷新')
return
}
if (res.ok) {
- const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile
+ const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile | SharedCollection
let appearanceError = false
try {
const response = await fetch(api + '/appearance', { cache: 'no-store' })
@@ -58,7 +78,8 @@ async function load() {
} catch {
appearanceError = true
}
- if (content.dir) renderDirectory(content)
+ if ('collection' in content) renderCollection(content)
+ else if (content.dir) renderDirectory(content)
else if ('content' in content) render(content)
else {
document.title = content.title
@@ -69,8 +90,8 @@ async function load() {
}
else if (res.status === 401) askPassword()
else if (res.status === 404) {
- message(selectedPath ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
- if (selectedPath) root.prepend(navigation())
+ message(selectedPath || selectedItem !== null ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
+ if (selectedPath || selectedItem !== null) root.prepend(navigation())
}
else message(`加载失败(${res.status})`)
}
@@ -112,7 +133,8 @@ function navigation() {
const nav = document.createElement('nav')
nav.className = 'rm-share-nav'
nav.setAttribute('aria-label', '分享导航')
- nav.append(Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '分享首页' }))
+ nav.append(Object.assign(document.createElement('a'), { href: pageUrl('', null), textContent: '分享首页' }))
+ if (selectedItem !== null) nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '主体首页' }))
const parts = selectedPath.split('/').filter(Boolean)
for (let i = 0; i < parts.length; i++) {
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
@@ -120,6 +142,21 @@ function navigation() {
return nav
}
+function renderCollection(collection: SharedCollection) {
+ document.title = collection.title
+ const heading = Object.assign(document.createElement('h1'), { textContent: collection.title })
+ const list = document.createElement('ul')
+ list.className = 'rm-share-files'
+ for (const item of collection.items) {
+ const row = document.createElement('li')
+ row.append(item.exists
+ ? Object.assign(document.createElement('a'), { href: pageUrl('', String(item.id)), textContent: item.title + (item.dir ? '/' : '') })
+ : Object.assign(document.createElement('span'), { textContent: item.title + '(已不可用)' }))
+ list.append(row)
+ }
+ root.replaceChildren(heading, list)
+}
+
function renderDirectory(directory: SharedDirectory) {
document.title = directory.title
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
@@ -148,7 +185,7 @@ function render(note: SharedNote) {
article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤
article.innerHTML = renderMarkdown(note.content)
- root.replaceChildren(...(selectedPath ? [navigation()] : []), header, article)
+ root.replaceChildren(...(selectedPath || selectedItem !== null ? [navigation()] : []), header, article)
resolveImages(article)
resolveLinks(article)
@@ -165,6 +202,7 @@ function resourceUrl(src: string): URL | null {
try {
const prefix = new URL(api + '/assets/', location.origin)
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
+ if (selectedItem !== null) target.searchParams.set('item', selectedItem)
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
} catch { return null }
}