feat: sharelink 资源下载管理插件 v1.1.0

- 下载资源管理与 /download/{slug} 下载链接
- 下载统计(次数/去重人数/明细/CSV 导出)
- 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱)
- 本地附件防直链(/upload/** 404)
- 文章引用扫描
- Console 前端:Vue3 + ui-plugin-bundler-kit
This commit is contained in:
shirainbown
2026-08-03 00:25:15 +08:00
commit aa4befa0ff
62 changed files with 7838 additions and 0 deletions
+512
View File
@@ -0,0 +1,512 @@
<script lang="ts" setup>
import {
createResource,
deleteResource,
getReferences,
listResources,
refreshReferences,
updateResource,
} from '@/api';
import ResourceFormModal from '@/components/ResourceFormModal.vue';
import RecordsDrawer from '@/components/RecordsDrawer.vue';
import type { DownloadResource, ReferencesMap } from '@/types';
import {
Dialog,
IconAddCircle,
IconClipboardLine,
IconDeleteBin,
IconExternalLinkLine,
IconRefreshLine,
IconRiPencilFill,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VSpace,
VSwitch,
VTag,
} from '@halo-dev/components';
import { onMounted, ref } from 'vue';
const resources = ref<DownloadResource[]>([]);
const loading = ref(false);
const references = ref<ReferencesMap>({});
const referencesLoading = ref(false);
const refreshingScan = ref(false);
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
const pendingDeletes = new Set<string>();
// 展开引用文章列表的 slug 集合
const expandedSlugs = ref<Set<string>>(new Set());
const formModalVisible = ref(false);
const editingResource = ref<DownloadResource | null>(null);
const recordsVisible = ref(false);
const recordsResource = ref<DownloadResource | null>(null);
function errorMessage(e: unknown): string {
const err = e as {
response?: { data?: { detail?: string; title?: string } };
message?: string;
};
return (
err?.response?.data?.detail ||
err?.response?.data?.title ||
err?.message ||
'请求失败,请稍后重试'
);
}
function downloadUrl(slug: string): string {
return `${window.location.origin}/download/${slug}`;
}
async function copyDownloadUrl(slug: string) {
const url = downloadUrl(slug);
try {
await navigator.clipboard.writeText(url);
Toast.success('下载链接已复制');
} catch {
// 剪贴板 API 不可用时的降级方案
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
Toast.success('下载链接已复制');
} catch {
Toast.error('复制失败,请手动复制');
}
document.body.removeChild(textarea);
}
}
async function fetchResources() {
loading.value = true;
try {
const data = await listResources();
resources.value = (data.items ?? []).filter(
(r) => !pendingDeletes.has(r.metadata.name)
);
} catch (e) {
Toast.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function fetchReferences() {
referencesLoading.value = true;
try {
references.value = (await getReferences()) ?? {};
} catch (e) {
Toast.error(errorMessage(e));
} finally {
referencesLoading.value = false;
}
}
async function handleRefreshScan() {
refreshingScan.value = true;
try {
references.value = (await refreshReferences()) ?? {};
Toast.success('引用扫描已刷新');
fetchResources();
} catch (e) {
Toast.error(errorMessage(e));
} finally {
refreshingScan.value = false;
}
}
function toggleExpand(slug: string) {
const next = new Set(expandedSlugs.value);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
expandedSlugs.value = next;
}
function handleCreate() {
editingResource.value = null;
formModalVisible.value = true;
}
function handleEdit(resource: DownloadResource) {
editingResource.value = resource;
formModalVisible.value = true;
}
function handleOpenRecords(resource: DownloadResource) {
recordsResource.value = resource;
recordsVisible.value = true;
}
async function handleToggleEnabled(resource: DownloadResource, value: boolean) {
const previous = resource.spec.enabled;
resource.spec.enabled = value;
try {
await updateResource(resource.metadata.name, {
...resource.spec,
enabled: value,
});
Toast.success(value ? '已启用' : '已停用');
} catch (e) {
resource.spec.enabled = previous;
Toast.error(errorMessage(e));
fetchResources();
}
}
function handleDelete(resource: DownloadResource) {
Dialog.warning({
title: '删除下载资源',
description: `确定要删除资源「${resource.spec.displayName}」吗?删除后下载链接 /download/${resource.spec.slug} 将立即失效。`,
confirmType: 'danger',
confirmText: '删除',
cancelText: '取消',
onConfirm: async () => {
try {
await deleteResource(resource.metadata.name);
pendingDeletes.add(resource.metadata.name);
setTimeout(
() => pendingDeletes.delete(resource.metadata.name),
3000
);
resources.value = resources.value.filter(
(r) => r.metadata.name !== resource.metadata.name
);
Toast.success(`已删除资源「${resource.spec.displayName}`);
fetchResources();
} catch (e) {
Toast.error(errorMessage(e));
}
},
});
}
function handleSaved(resource: DownloadResource, created: boolean) {
fetchResources();
if (created && resource.spec.enabled) {
Toast.success(`下载链接:/download/${resource.spec.slug}`);
}
}
onMounted(() => {
fetchResources();
fetchReferences();
});
</script>
<template>
<VPageHeader title="下载管理" />
<div class="download-manager-page m-4">
<VCard>
<div class="card-toolbar">
<div class="text-sm text-gray-500">
{{ resources.length }} 个下载资源
</div>
<VSpace>
<VButton size="sm" type="primary" @click="handleCreate">
<template #icon>
<IconAddCircle />
</template>
新建资源
</VButton>
<VButton
size="sm"
type="secondary"
:loading="refreshingScan"
@click="handleRefreshScan"
>
<template #icon>
<IconRefreshLine />
</template>
刷新引用扫描
</VButton>
<VButton
size="sm"
type="default"
:loading="loading || referencesLoading"
@click="
fetchResources();
fetchReferences();
"
>
刷新
</VButton>
</VSpace>
</div>
<VLoading v-if="loading" />
<VEmpty
v-else-if="resources.length === 0"
title="暂无下载资源"
message="点击右上角「新建资源」创建第一个资源下载链接"
/>
<div v-else class="table-wrapper">
<table class="resource-table">
<thead>
<tr>
<th>名称</th>
<th>下载链接</th>
<th>附件</th>
<th>邮箱验证</th>
<th>启用</th>
<th>引用文章</th>
<th>下载数</th>
<th>下载人数</th>
<th class="w-56">操作</th>
</tr>
</thead>
<tbody>
<template v-for="item in resources" :key="item.metadata.name">
<tr>
<td class="font-medium">
<div>{{ item.spec.displayName }}</div>
<div
v-if="item.spec.description"
class="text-xs text-gray-400 desc-line"
:title="item.spec.description"
>
{{ item.spec.description }}
</div>
</td>
<td>
<div class="link-cell">
<code class="link-code">/download/{{ item.spec.slug }}</code>
<VButton
size="sm"
type="default"
ghost
@click="copyDownloadUrl(item.spec.slug)"
>
<template #icon>
<IconClipboardLine />
</template>
复制
</VButton>
</div>
</td>
<td class="cell-ellipsis" :title="item.spec.attachmentName">
{{ item.spec.attachmentName || '-' }}
</td>
<td>
<VTag :theme="item.spec.requireEmailVerify ? 'primary' : 'default'">
{{ item.spec.requireEmailVerify ? '需要' : '不需要' }}
</VTag>
</td>
<td>
<VSwitch
:model-value="item.spec.enabled ?? false"
@change="(value: boolean) => handleToggleEnabled(item, value)"
/>
</td>
<td>
<VButton
size="sm"
type="default"
ghost
@click="toggleExpand(item.spec.slug)"
>
{{ item.stats?.referenceCount ?? 0 }}
{{ expandedSlugs.has(item.spec.slug) ? '▲' : '▼' }}
</VButton>
</td>
<td>{{ item.status?.downloadCount ?? 0 }}</td>
<td>{{ item.stats?.downloaderCount ?? 0 }}</td>
<td>
<VSpace>
<VButton
size="sm"
type="default"
@click="handleOpenRecords(item)"
>
记录
</VButton>
<VButton
size="sm"
type="secondary"
@click="handleEdit(item)"
>
<template #icon>
<IconRiPencilFill />
</template>
编辑
</VButton>
<VButton
size="sm"
type="danger"
ghost
@click="handleDelete(item)"
>
<template #icon>
<IconDeleteBin />
</template>
删除
</VButton>
</VSpace>
</td>
</tr>
<tr v-if="expandedSlugs.has(item.spec.slug)" class="refs-row">
<td colspan="9">
<div
v-if="(references[item.spec.slug] ?? []).length === 0"
class="text-sm text-gray-400 refs-empty"
>
暂无文章引用该资源可在编辑器中插入 /download/{{
item.spec.slug
}}
链接后点击刷新引用扫描
</div>
<ul v-else class="refs-list">
<li
v-for="refItem in references[item.spec.slug]"
:key="refItem.postName"
>
<span class="refs-title">{{ refItem.title }}</span>
<a
class="refs-link"
:href="refItem.editorUrl"
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLinkLine /> 编辑器
</a>
<a
class="refs-link"
:href="refItem.permalink"
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLinkLine /> 访问
</a>
</li>
</ul>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</VCard>
<ResourceFormModal
v-model:visible="formModalVisible"
:resource="editingResource"
@saved="handleSaved"
/>
<RecordsDrawer
v-model:visible="recordsVisible"
:resource="recordsResource"
/>
</div>
</template>
<style scoped>
.card-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.table-wrapper {
overflow-x: auto;
}
.resource-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.resource-table th,
.resource-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
white-space: nowrap;
}
.resource-table th {
color: #6b7280;
font-weight: 500;
background: #fafafa;
}
.link-cell {
display: flex;
align-items: center;
gap: 8px;
}
.link-code {
background: #f3f4f6;
border-radius: 4px;
padding: 2px 6px;
font-size: 12px;
}
.cell-ellipsis {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.desc-line {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.refs-row td {
background: #fafafa;
white-space: normal;
}
.refs-empty {
padding: 4px 0;
}
.refs-list {
margin: 0;
padding: 0;
list-style: none;
}
.refs-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.refs-title {
font-weight: 500;
}
.refs-link {
display: inline-flex;
align-items: center;
gap: 2px;
color: #2563eb;
font-size: 13px;
text-decoration: none;
}
.refs-link:hover {
text-decoration: underline;
}
</style>