feat: sharelink 资源下载管理插件 v1.1.0
- 下载资源管理与 /download/{slug} 下载链接
- 下载统计(次数/去重人数/明细/CSV 导出)
- 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱)
- 本地附件防直链(/upload/** 404)
- 文章引用扫描
- Console 前端:Vue3 + ui-plugin-bundler-kit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user