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
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org
+59
View File
@@ -0,0 +1,59 @@
# sharelink console UI
Halo 2.x 插件「资源下载管理」的 console 后台前端(Vue 3 + rsbuild +
@halo-dev/ui-plugin-bundler-kit)。工程结构照搬
`blog-comment/packages/ui`
## 命令
```bash
pnpm install
pnpm build # 产物输出到 ../src/main/resources/console(打进插件 jar
pnpm dev # watch 模式,输出到 ../build/resources/main/console
pnpm type-check # vue-tsc 类型检查(构建流程不依赖)
```
## 路由与权限
- 路由:`/download-manager`,挂在 `Root` 下,菜单组 `content`priority 52
- 权限 meta`plugin:sharelink:view`(见
`src/main/resources/extensions/role-templates.yaml`
`role-template-sharelink-view`
- 注意:角色的 `ui-permissions` 里还定义了 `plugin:sharelink:manage`,但
console 菜单只挂了 view 权限;前端未对 manage 操作做按钮级权限隐藏,
写操作是否放行完全由后端 RBAC 决定。
## API 契约(后端并行开发中)
Base`/apis/console.api.sharelink.halo.run/v1alpha1`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/download-resources` | `{ items: [...], total }`(非标准 ListResult |
| POST | `/download-resources` | 创建,**前端只提交 `{ metadata: { name: '' }, spec }`name 由后端生成** |
| GET/PUT/DELETE | `/download-resources/{name}` | 单资源读写删,PUT 提交完整对象 |
| GET | `/download-records?resourceSlug=&page=&size=` | Halo 标准 ListResult |
| DELETE | `/download-records/{name}` | 删除单条记录 |
| GET | `/download-records/-/export?resourceSlug=` | CSV,前端用 `window.open` 直接导航下载 |
| GET | `/references` | `{ slug: [{ postName, title, permalink, editorUrl }] }` |
| POST | `/references/-/refresh` | 强制重扫,返回同上 |
## 前端字段假设(联调时若与后端不一致,以此为准核对)
1. `DownloadResource.spec``slug``displayName``description`
`attachmentName``requireEmailVerify``enabled``status.downloadCount`
`stats.downloaderCount``stats.referenceCount`。其中 `stats` 是 Halo
自定义资源里不常见的顶层字段(与 `status` 平级),若后端改为放进
`status`,需同步修改 `src/types.ts``DownloadManager.vue` 的两处取值。
2. 列表 GET `/download-resources` 返回 `{ items, total }`,不带分页参数,
前端一次性拉全量。
3. `references` 的 key 是 **slug**(不是 metadata.name)。
4. 引用项 `editorUrl` / `permalink` 均为可直接打开的相对或绝对路径,
前端 `target="_blank"` 打开。
5. 下载记录 `spec.downloadedAt` 为 ISO 时间字符串;`spec.email` 可能为空
(匿名下载)。
6. CSV 导出端点接受 session cookie 直接 GET 导航,无需 axios blob 下载。
7. 附件选择:`AttachmentSelectorModal` 是 Halo Console **全局注册**的业务
组件(不从 `@halo-dev/components` 导出),`v-model:visible` 控制显隐,
`@select` 回调附件数组,前端存 `attachment.metadata.name`
`spec.attachmentName`
Vendored
+23
View File
@@ -0,0 +1,23 @@
/// <reference types="@rsbuild/core/types" />
// AttachmentSelectorModal 是 Halo Console 全局注册的业务组件(非 @halo-dev/components 导出),
// 见 https://docs.halo.run/developer-guide/plugin/api-reference/ui/components/attachment-selector-modal/
declare module 'vue' {
interface GlobalComponents {
AttachmentSelectorModal: import('vue').DefineComponent<
{ visible?: boolean },
{},
{},
{},
{},
import('vue').ComponentOptionsMixin,
import('vue').ComponentOptionsMixin,
{
'update:visible': (value: boolean) => void;
select: (attachments: unknown[]) => void;
}
>;
}
}
export {};
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sharelink-console-ui",
"type": "module",
"private": true,
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild build --watch --env-mode=development",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@halo-dev/api-client": "2.23.0",
"@halo-dev/components": "^2.21.0",
"@halo-dev/ui-shared": "^2.22.0",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {
"@halo-dev/ui-plugin-bundler-kit": "^2.21.2",
"@rsbuild/core": "^2.0.3",
"@rsbuild/plugin-vue": "^1.2.7",
"@types/node": "^20.19.24",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vue-tsc": "^2.2.12"
}
}
+2190
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
import { pluginVue } from '@rsbuild/plugin-vue';
const MANIFEST_PATH = '../src/main/resources/plugin.yaml';
const OUT_DIR_PROD = '../src/main/resources/console';
const OUT_DIR_DEV = '../build/resources/main/console';
export default rsbuildConfig({
manifestPath: MANIFEST_PATH,
rsbuild: ({ envMode }) => {
const isProduction = envMode === 'production';
const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV;
return {
resolve: {
alias: {
'@': './src',
},
},
plugins: [pluginVue()],
output: {
distPath: {
root: outDir,
},
},
};
},
});
+127
View File
@@ -0,0 +1,127 @@
import { axiosInstance } from '@halo-dev/api-client';
import type {
DownloadResource,
DownloadResourceList,
DownloadResourceSpec,
DownloadRecord,
ListResult,
ReferencesMap,
} from '@/types';
export const API_BASE = '/apis/console.api.sharelink.halo.run/v1alpha1';
// 后端资源 VO 为扁平结构,这里统一归一化为 Halo 风格结构供组件使用
interface ResourceVo {
name: string;
slug: string;
displayName: string;
description?: string;
attachmentName?: string;
requireEmailVerify?: boolean;
enabled?: boolean;
downloadUrl?: string;
creationTimestamp?: string;
stats?: {
downloadCount?: number;
downloaderCount?: number;
referenceCount?: number;
};
}
function normalizeResource(vo: ResourceVo): DownloadResource {
return {
metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp },
spec: {
slug: vo.slug,
displayName: vo.displayName,
description: vo.description,
attachmentName: vo.attachmentName,
requireEmailVerify: vo.requireEmailVerify,
enabled: vo.enabled,
},
status: { downloadCount: vo.stats?.downloadCount ?? 0 },
stats: {
downloaderCount: vo.stats?.downloaderCount ?? 0,
referenceCount: vo.stats?.referenceCount ?? 0,
},
};
}
export async function listResources(): Promise<DownloadResourceList> {
const { data } = await axiosInstance.get<ResourceVo[]>(
`${API_BASE}/download-resources`
);
const items = (data ?? []).map(normalizeResource);
return { items, total: items.length };
}
// 后端创建/更新均接收扁平的 spec 字段(ResourceRequest
export async function createResource(
spec: DownloadResourceSpec
): Promise<DownloadResource> {
const { data } = await axiosInstance.post<ResourceVo>(
`${API_BASE}/download-resources`,
spec
);
return normalizeResource(data);
}
export async function getResource(name: string): Promise<DownloadResource> {
const { data } = await axiosInstance.get<ResourceVo>(
`${API_BASE}/download-resources/${name}`
);
return normalizeResource(data);
}
export async function updateResource(
name: string,
spec: DownloadResourceSpec
): Promise<DownloadResource> {
const { data } = await axiosInstance.put<ResourceVo>(
`${API_BASE}/download-resources/${name}`,
spec
);
return normalizeResource(data);
}
export async function deleteResource(name: string): Promise<void> {
await axiosInstance.delete(`${API_BASE}/download-resources/${name}`);
}
export async function listRecords(params: {
resourceSlug?: string;
page?: number;
size?: number;
}): Promise<ListResult<DownloadRecord>> {
const { data } = await axiosInstance.get<ListResult<DownloadRecord>>(
`${API_BASE}/download-records`,
{ params }
);
return data;
}
export async function deleteRecord(name: string): Promise<void> {
await axiosInstance.delete(`${API_BASE}/download-records/${name}`);
}
// CSV 导出走浏览器直接导航(console 端点接受 session cookie
export function recordsExportUrl(resourceSlug?: string): string {
const query = resourceSlug
? `?resourceSlug=${encodeURIComponent(resourceSlug)}`
: '';
return `${API_BASE}/download-records/-/export${query}`;
}
export async function getReferences(): Promise<ReferencesMap> {
const { data } = await axiosInstance.get<ReferencesMap>(
`${API_BASE}/references`
);
return data;
}
export async function refreshReferences(): Promise<ReferencesMap> {
const { data } = await axiosInstance.post<ReferencesMap>(
`${API_BASE}/references/-/refresh`
);
return data;
}
+262
View File
@@ -0,0 +1,262 @@
<script lang="ts" setup>
import {
deleteRecord,
listRecords,
recordsExportUrl,
} from '@/api';
import type { DownloadRecord, DownloadResource } from '@/types';
import {
Dialog,
IconDeleteBin,
IconRefreshLine,
IconRiUpload2Fill,
Toast,
VButton,
VEmpty,
VLoading,
VModal,
VPagination,
VSpace,
} from '@halo-dev/components';
import { ref, watch } from 'vue';
const props = defineProps<{
visible: boolean;
resource: DownloadResource | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
}>();
const records = ref<DownloadRecord[]>([]);
const loading = ref(false);
const page = ref(1);
const size = ref(20);
const total = ref(0);
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
const pendingDeletes = new Set<string>();
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 formatTime(iso?: string): string {
if (!iso) {
return '-';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return iso;
}
return date.toLocaleString('zh-CN', { hour12: false });
}
async function fetchRecords() {
if (!props.resource) {
return;
}
loading.value = true;
try {
const data = await listRecords({
resourceSlug: props.resource.spec.slug,
page: page.value,
size: size.value,
});
records.value = (data.items ?? []).filter(
(r) => !pendingDeletes.has(r.metadata.name)
);
total.value = data.total ?? 0;
} catch (e) {
Toast.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function handleExport() {
// 直接导航下载(console 端点接受 session cookie
window.open(recordsExportUrl(props.resource?.spec.slug), '_blank');
}
function handleDelete(record: DownloadRecord) {
Dialog.warning({
title: '删除下载记录',
description: '确定要删除这条下载记录吗?删除后不可恢复。',
confirmType: 'danger',
confirmText: '删除',
cancelText: '取消',
onConfirm: async () => {
try {
await deleteRecord(record.metadata.name);
// 删除当前页最后一条时回退一页
if (records.value.length === 1 && page.value > 1) {
page.value -= 1;
}
pendingDeletes.add(record.metadata.name);
setTimeout(() => pendingDeletes.delete(record.metadata.name), 3000);
records.value = records.value.filter(
(r) => r.metadata.name !== record.metadata.name
);
Toast.success('记录已删除');
fetchRecords();
} catch (e) {
Toast.error(errorMessage(e));
}
},
});
}
watch(
() => [props.visible, props.resource?.metadata.name],
([visible]) => {
if (visible) {
page.value = 1;
fetchRecords();
}
}
);
</script>
<template>
<VModal
:visible="visible"
:title="`下载记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
:width="960"
mount-to-body
@update:visible="emit('update:visible', $event)"
@close="emit('update:visible', false)"
>
<div class="records-toolbar">
<div class="text-sm text-gray-500"> {{ total }} 条记录</div>
<VSpace>
<VButton size="sm" type="default" @click="handleExport">
<template #icon>
<IconRiUpload2Fill />
</template>
导出 CSV
</VButton>
<VButton
size="sm"
type="secondary"
:loading="loading"
@click="fetchRecords"
>
<template #icon>
<IconRefreshLine />
</template>
刷新
</VButton>
</VSpace>
</div>
<VLoading v-if="loading" />
<VEmpty
v-else-if="records.length === 0"
title="暂无下载记录"
message="该资源还没有产生下载"
/>
<div v-else class="table-wrapper">
<table class="records-table">
<thead>
<tr>
<th>时间</th>
<th>邮箱</th>
<th>IP</th>
<th>User-Agent</th>
<th class="w-24">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.metadata.name">
<td>{{ formatTime(record.spec.downloadedAt) }}</td>
<td>{{ record.spec.email || '匿名' }}</td>
<td>{{ record.spec.ip || '-' }}</td>
<td class="ua-cell" :title="record.spec.userAgent">
{{ record.spec.userAgent || '-' }}
</td>
<td>
<VButton
size="sm"
type="danger"
ghost
@click="handleDelete(record)"
>
<template #icon>
<IconDeleteBin />
</template>
删除
</VButton>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="total > 0" class="records-pagination">
<VPagination
v-model:page="page"
v-model:size="size"
:total="total"
:size-options="[20, 50, 100]"
show-total
@change="fetchRecords"
/>
</div>
</VModal>
</template>
<style scoped>
.records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.table-wrapper {
overflow-x: auto;
}
.records-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.records-table th,
.records-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
}
.records-table th {
color: #6b7280;
font-weight: 500;
background: #fafafa;
}
.ua-cell {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
}
.records-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
+354
View File
@@ -0,0 +1,354 @@
<script lang="ts" setup>
import { createResource, updateResource } from '@/api';
import type { DownloadResource, DownloadResourceSpec } from '@/types';
import { Toast, VButton, VModal, VSwitch } from '@halo-dev/components';
import { computed, ref, watch } from 'vue';
// AttachmentSelectorModal 选中的附件对象(只取需要的字段,结构向后兼容)
interface PickedAttachment {
metadata?: { name?: string };
spec?: {
displayName?: string;
size?: number;
};
}
const props = defineProps<{
visible: boolean;
resource: DownloadResource | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'saved', resource: DownloadResource, created: boolean): void;
}>();
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const isEdit = computed(() => props.resource !== null);
const form = ref<DownloadResourceSpec>({
slug: '',
displayName: '',
description: '',
attachmentName: '',
requireEmailVerify: false,
enabled: true,
});
// 本次从附件选择器里选到的附件(用于展示文件名 + 大小)
const pickedAttachment = ref<PickedAttachment | null>(null);
const attachmentSelectorVisible = ref(false);
const saving = ref(false);
watch(
() => props.visible,
(visible) => {
if (!visible) {
return;
}
pickedAttachment.value = null;
if (props.resource) {
const spec = props.resource.spec;
form.value = {
slug: spec.slug,
displayName: spec.displayName,
description: spec.description ?? '',
attachmentName: spec.attachmentName ?? '',
requireEmailVerify: spec.requireEmailVerify ?? false,
enabled: spec.enabled ?? true,
};
} else {
form.value = {
slug: '',
displayName: '',
description: '',
attachmentName: '',
requireEmailVerify: false,
enabled: true,
};
}
}
);
function close() {
emit('update:visible', false);
}
function formatSize(size?: number): string {
if (!size || size <= 0) {
return '';
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
// 从附件文件名派生 slug 建议值:取主文件名,转小写,非法字符折叠为 -
function deriveSlug(filename: string): string {
const base = filename.replace(/\.[^.]+$/, '');
const slug = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/^-+|-+$/g, '');
return SLUG_PATTERN.test(slug) ? slug : '';
}
function randomSlug(): string {
return Math.random().toString(36).slice(2, 10);
}
function handleAttachmentSelect(attachments: unknown[]) {
const attachment = attachments?.[0] as PickedAttachment | undefined;
const name = attachment?.metadata?.name;
if (!attachment || !name) {
return;
}
pickedAttachment.value = attachment;
form.value.attachmentName = name;
// 新建且 slug 为空时,从文件名派生建议值;派生失败给随机短串
if (!isEdit.value && !form.value.slug) {
const displayName = attachment.spec?.displayName ?? name;
form.value.slug = deriveSlug(displayName) || randomSlug();
}
attachmentSelectorVisible.value = false;
}
const attachmentDisplay = computed(() => {
if (pickedAttachment.value) {
const name =
pickedAttachment.value.spec?.displayName ??
pickedAttachment.value.metadata?.name ??
'';
const size = formatSize(pickedAttachment.value.spec?.size);
return size ? `${name}${size}` : name;
}
return form.value.attachmentName || '';
});
async function handleSave() {
const displayName = form.value.displayName.trim();
const slug = form.value.slug.trim();
if (!displayName) {
Toast.warning('请填写资源名称');
return;
}
if (!slug) {
Toast.warning('请填写 slug');
return;
}
if (!isEdit.value && !SLUG_PATTERN.test(slug)) {
Toast.warning(
'slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)'
);
return;
}
saving.value = true;
try {
if (isEdit.value && props.resource) {
const payload: DownloadResourceSpec = {
...props.resource.spec,
displayName,
description: form.value.description?.trim() ?? '',
attachmentName: form.value.attachmentName,
requireEmailVerify: form.value.requireEmailVerify,
enabled: form.value.enabled,
};
const saved = await updateResource(props.resource.metadata.name, payload);
Toast.success('资源已更新');
emit('saved', saved, false);
} else {
const saved = await createResource({
slug,
displayName,
description: form.value.description?.trim() ?? '',
attachmentName: form.value.attachmentName,
requireEmailVerify: form.value.requireEmailVerify,
enabled: form.value.enabled,
});
Toast.success('资源已创建');
emit('saved', saved, true);
}
close();
} catch (e) {
const err = e as {
response?: { data?: { detail?: string; title?: string } };
message?: string;
};
Toast.error(
err?.response?.data?.detail ||
err?.response?.data?.title ||
err?.message ||
'保存失败,请稍后重试'
);
} finally {
saving.value = false;
}
}
</script>
<template>
<VModal
:visible="visible"
:title="isEdit ? '编辑下载资源' : '新建下载资源'"
:width="640"
@update:visible="emit('update:visible', $event)"
@close="close"
>
<div class="resource-form">
<div class="form-item form-item--column">
<label class="form-label">
资源名称 <span class="required">*</span>
</label>
<input
v-model="form.displayName"
class="form-input"
type="text"
placeholder="例如:产品白皮书 PDF"
/>
</div>
<div class="form-item form-item--column">
<label class="form-label">
slug <span class="required">*</span>
</label>
<input
v-model="form.slug"
class="form-input"
type="text"
:disabled="isEdit"
placeholder="例如:whitepaper-2024(下载链接为 /download/{slug}"
/>
<div v-if="isEdit" class="form-hint">slug 创建后不可修改</div>
<div v-else class="form-hint">
小写字母数字中划线以字母或数字开头最长 64
</div>
</div>
<div class="form-item form-item--column">
<label class="form-label">描述</label>
<textarea
v-model="form.description"
class="form-input"
rows="3"
placeholder="可选,资源用途说明"
/>
</div>
<div class="form-item form-item--column">
<label class="form-label">附件</label>
<div class="attachment-picker">
<VButton size="sm" type="default" @click="attachmentSelectorVisible = true">
选择附件
</VButton>
<span v-if="attachmentDisplay" class="attachment-name" :title="form.attachmentName">
{{ attachmentDisplay }}
</span>
<span v-else class="text-sm text-gray-400">未选择</span>
</div>
</div>
<div class="form-item">
<span class="form-label">需要邮箱验证</span>
<VSwitch v-model="form.requireEmailVerify" />
</div>
<div class="form-item">
<span class="form-label">启用</span>
<VSwitch v-model="form.enabled" />
</div>
</div>
<template #footer>
<VButton type="primary" :loading="saving" @click="handleSave">
保存
</VButton>
<VButton type="default" @click="close">取消</VButton>
</template>
</VModal>
<AttachmentSelectorModal
v-model:visible="attachmentSelectorVisible"
@select="handleAttachmentSelect"
/>
</template>
<style scoped>
.resource-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-item--column {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.form-label {
font-size: 14px;
color: #374151;
}
.required {
color: #dc2626;
}
.form-input {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 4px;
padding: 6px 10px;
font-size: 14px;
outline: none;
}
.form-input:focus {
border-color: #2563eb;
}
.form-input:disabled {
background: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
.form-hint {
font-size: 12px;
color: #9ca3af;
}
.attachment-picker {
display: flex;
align-items: center;
gap: 10px;
}
.attachment-name {
font-size: 13px;
color: #374151;
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import { IconArrowDownCircleLine } from '@halo-dev/components';
import { definePlugin } from '@halo-dev/ui-shared';
import { markRaw } from 'vue';
export default definePlugin({
components: {},
routes: [
{
parentName: 'Root',
route: {
path: '/download-manager',
name: 'DownloadManager',
component: () => import('@/views/DownloadManager.vue'),
meta: {
title: '下载管理',
permissions: ['plugin:sharelink:view'],
menu: {
name: '下载管理',
group: 'content',
icon: markRaw(IconArrowDownCircleLine),
priority: 52,
},
},
},
},
],
extensionPoints: {},
});
+71
View File
@@ -0,0 +1,71 @@
// 后端 API 契约类型,字段假设见 ui/README.md
export interface DownloadResourceSpec {
slug: string;
displayName: string;
description?: string;
attachmentName?: string;
requireEmailVerify?: boolean;
enabled?: boolean;
}
export interface DownloadResource {
apiVersion?: string;
kind?: string;
metadata: {
name: string;
creationTimestamp?: string;
[key: string]: unknown;
};
spec: DownloadResourceSpec;
status?: {
downloadCount?: number;
[key: string]: unknown;
};
stats?: {
downloaderCount?: number;
referenceCount?: number;
[key: string]: unknown;
};
}
export interface DownloadResourceList {
items: DownloadResource[];
total: number;
}
export interface DownloadRecord {
metadata: {
name: string;
[key: string]: unknown;
};
spec: {
resourceSlug: string;
email?: string;
ip?: string;
userAgent?: string;
downloadedAt?: string;
};
}
// Halo 标准 ListResult 结构
export interface ListResult<T> {
items: T[];
page: number;
size: number;
total: number;
first?: boolean;
last?: boolean;
hasNext?: boolean;
hasPrevious?: boolean;
totalPages?: number;
}
export interface ReferenceInfo {
postName: string;
title: string;
permalink: string;
editorUrl: string;
}
// slug -> 引用文章列表
export type ReferencesMap = Record<string, ReferenceInfo[]>;
+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>
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}