Files
sharelink/ui/src/views/DownloadManager.vue
T
shirainbown aa4befa0ff feat: sharelink 资源下载管理插件 v1.1.0
- 下载资源管理与 /download/{slug} 下载链接
- 下载统计(次数/去重人数/明细/CSV 导出)
- 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱)
- 本地附件防直链(/upload/** 404)
- 文章引用扫描
- Console 前端:Vue3 + ui-plugin-bundler-kit
2026-08-03 00:25:15 +08:00

513 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>