Support live collection shares with stable member scopes
This commit is contained in:
1 parent
ffc95d6dbc
commit
828d50f242
13 files changed
+551
-84
No files matched your search
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { basename } from './paths'
|
||||
import SharePanel from './SharePanel.vue'
|
||||
|
||||
defineProps<{ path: string }>()
|
||||
const props = defineProps<{ path: string | string[] }>()
|
||||
const title = computed(() => typeof props.path === 'string' ? basename(props.path) : `${props.path.length} 项内容`)
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${basename(path)}”`">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${title}”`">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>分享“{{ basename(path) }}”</h2>
|
||||
<h2>分享“{{ title }}”</h2>
|
||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<SharePanel :path="path" />
|
||||
|
||||
@@ -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)
|
||||
</select>
|
||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
|
||||
</form>
|
||||
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。</p>
|
||||
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。多选生成一个集合链接,部分主体删除不影响其他项。</p>
|
||||
<p v-if="failed" class="rm-tree-empty">读取分享失败</p>
|
||||
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="s in shares ?? []" :key="s.token">
|
||||
<Icon name="link" />
|
||||
<div class="rm-trash-info">
|
||||
<span v-if="!path" class="rm-trash-path">{{ s.path }}</span>
|
||||
<span v-if="!path" class="rm-trash-path">{{ s.items.map((item) => item.path).join('、') }}</span>
|
||||
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(s.createdAt) }} 创建
|
||||
<template v-if="s.hasPassword"> · 有密码</template>
|
||||
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
|
||||
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
|
||||
<template v-if="s.exists && s.items.some((item) => !item.exists)"> · <span class="rm-share-bad">部分主体已不存在</span></template>
|
||||
<template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
||||
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
||||
import { minimalPaths } from './selection'
|
||||
import { batchFiles, exportFiles } from './batch'
|
||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
||||
|
||||
@@ -86,7 +87,7 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
|
||||
} else if (action === 'import') ws.importing = path
|
||||
else if (action === 'images') ws.images = path
|
||||
else if (action === 'rename') ws.renaming = path
|
||||
else if (action === 'share') ws.sharing = path
|
||||
else if (action === 'share') ws.sharing = minimalPaths(selectedPaths)
|
||||
else if (action === 'move') ws.moving = [...selectedPaths]
|
||||
else if (action === 'export') void exportFiles([...selectedPaths])
|
||||
else void batchFiles('delete', [...selectedPaths])
|
||||
@@ -171,7 +172,7 @@ function onDrop(e: DragEvent) {
|
||||
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
||||
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||
<button v-if="!image && selectedPaths.size === 1" @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||
<button @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
||||
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||
</div>
|
||||
|
||||
+4
-3
@@ -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<Share>(request('POST', '/api/v1/shares', { json: { path, password, expiresIn } })),
|
||||
createShare: (path: string | string[], password: string, expiresIn: number) =>
|
||||
json<Share>(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,
|
||||
|
||||
@@ -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,
|
||||
// 导入目标目录,空字符串为根目录
|
||||
|
||||
+47
-9
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user