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,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
@@ -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 {};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2190
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: {},
|
||||
});
|
||||
@@ -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[]>;
|
||||
@@ -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>
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user