feat: 新增受保护链接并将控制台合并为资源访问管理
- 新增 ProtectedLink/LinkVisitRecord,/link/{slug} 门禁页 + 一次性令牌跳转
- 复用邮箱验证体系,验证码邮件区分下载/链接模板
- 控制台下载资源与受保护链接合并为「资源访问管理」单页(页签切换)
- 引用扫描支持 /link/ 链接,角色模板/设置/文档同步更新
- 版本 1.3.0
This commit is contained in:
@@ -4,7 +4,11 @@ import type {
|
||||
DownloadResourceList,
|
||||
DownloadResourceSpec,
|
||||
DownloadRecord,
|
||||
LinkVisitRecord,
|
||||
ListResult,
|
||||
ProtectedLink,
|
||||
ProtectedLinkList,
|
||||
ProtectedLinkSpec,
|
||||
ReferencesMap,
|
||||
} from '@/types';
|
||||
|
||||
@@ -125,3 +129,117 @@ export async function refreshReferences(): Promise<ReferencesMap> {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- 受保护链接 ----------
|
||||
|
||||
interface LinkVo {
|
||||
name: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
targetUrl?: string;
|
||||
requireEmailVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
visitUrl?: string;
|
||||
creationTimestamp?: string;
|
||||
stats?: {
|
||||
visitCount?: number;
|
||||
visitorCount?: number;
|
||||
referenceCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLink(vo: LinkVo): ProtectedLink {
|
||||
return {
|
||||
metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp },
|
||||
spec: {
|
||||
slug: vo.slug,
|
||||
displayName: vo.displayName,
|
||||
description: vo.description,
|
||||
targetUrl: vo.targetUrl,
|
||||
requireEmailVerify: vo.requireEmailVerify,
|
||||
enabled: vo.enabled,
|
||||
},
|
||||
status: { visitCount: vo.stats?.visitCount ?? 0 },
|
||||
stats: {
|
||||
visitorCount: vo.stats?.visitorCount ?? 0,
|
||||
referenceCount: vo.stats?.referenceCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLinks(): Promise<ProtectedLinkList> {
|
||||
const { data } = await axiosInstance.get<LinkVo[]>(
|
||||
`${API_BASE}/protected-links`
|
||||
);
|
||||
const items = (data ?? []).map(normalizeLink);
|
||||
return { items, total: items.length };
|
||||
}
|
||||
|
||||
export async function createLink(spec: ProtectedLinkSpec): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.post<LinkVo>(
|
||||
`${API_BASE}/protected-links`,
|
||||
spec
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function getLink(name: string): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.get<LinkVo>(
|
||||
`${API_BASE}/protected-links/${name}`
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function updateLink(
|
||||
name: string,
|
||||
spec: ProtectedLinkSpec
|
||||
): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.put<LinkVo>(
|
||||
`${API_BASE}/protected-links/${name}`,
|
||||
spec
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function deleteLink(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/protected-links/${name}`);
|
||||
}
|
||||
|
||||
export async function listLinkRecords(params: {
|
||||
linkSlug?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<ListResult<LinkVisitRecord>> {
|
||||
const { data } = await axiosInstance.get<ListResult<LinkVisitRecord>>(
|
||||
`${API_BASE}/link-visit-records`,
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteLinkRecord(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/link-visit-records/${name}`);
|
||||
}
|
||||
|
||||
// CSV 导出走浏览器直接导航(console 端点接受 session cookie)
|
||||
export function linkRecordsExportUrl(linkSlug?: string): string {
|
||||
const query = linkSlug
|
||||
? `?linkSlug=${encodeURIComponent(linkSlug)}`
|
||||
: '';
|
||||
return `${API_BASE}/link-visit-records/-/export${query}`;
|
||||
}
|
||||
|
||||
export async function getLinkReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.get<ReferencesMap>(
|
||||
`${API_BASE}/link-references`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshLinkReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.post<ReferencesMap>(
|
||||
`${API_BASE}/link-references/-/refresh`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<script lang="ts" setup>
|
||||
import { createLink, updateLink } from '@/api';
|
||||
import type { ProtectedLink, ProtectedLinkSpec } from '@/types';
|
||||
import { Toast, VButton, VModal, VSwitch } from '@halo-dev/components';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
link: ProtectedLink | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'saved', link: ProtectedLink, created: boolean): void;
|
||||
}>();
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
const isEdit = computed(() => props.link !== null);
|
||||
|
||||
const form = ref<ProtectedLinkSpec>({
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
targetUrl: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
if (props.link) {
|
||||
const spec = props.link.spec;
|
||||
form.value = {
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
description: spec.description ?? '',
|
||||
targetUrl: spec.targetUrl ?? '',
|
||||
requireEmailVerify: spec.requireEmailVerify ?? false,
|
||||
enabled: spec.enabled ?? true,
|
||||
};
|
||||
} else {
|
||||
form.value = {
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
targetUrl: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
// 从目标 URL 派生 slug 建议值:优先取路径最后一段,其次取主机名
|
||||
function deriveSlugFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
const raw =
|
||||
parsed.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.pop() ?? parsed.hostname;
|
||||
const slug = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return SLUG_PATTERN.test(slug) ? slug : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function randomSlug(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function isValidTargetUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
!!parsed.hostname
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const displayName = form.value.displayName.trim();
|
||||
const slug = form.value.slug.trim();
|
||||
const targetUrl = (form.value.targetUrl ?? '').trim();
|
||||
if (!displayName) {
|
||||
Toast.warning('请填写链接名称');
|
||||
return;
|
||||
}
|
||||
if (!slug) {
|
||||
Toast.warning('请填写 slug');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && !SLUG_PATTERN.test(slug)) {
|
||||
Toast.warning(
|
||||
'slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isValidTargetUrl(targetUrl)) {
|
||||
Toast.warning('目标链接必须是合法的 http/https 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.link) {
|
||||
const payload: ProtectedLinkSpec = {
|
||||
...props.link.spec,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
targetUrl,
|
||||
requireEmailVerify: form.value.requireEmailVerify,
|
||||
enabled: form.value.enabled,
|
||||
};
|
||||
const saved = await updateLink(props.link.metadata.name, payload);
|
||||
Toast.success('链接已更新');
|
||||
emit('saved', saved, false);
|
||||
} else {
|
||||
const saved = await createLink({
|
||||
slug,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
targetUrl,
|
||||
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="link-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="例如:内部文档"
|
||||
/>
|
||||
</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="例如:internal-docs(访问链接为 /link/{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">
|
||||
目标链接 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.targetUrl"
|
||||
class="form-input"
|
||||
type="url"
|
||||
placeholder="https://example.com/secret-page"
|
||||
@blur="
|
||||
if (!isEdit && !form.slug && form.targetUrl) {
|
||||
form.slug = deriveSlugFromUrl(form.targetUrl) || randomSlug();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="form-hint">
|
||||
访客完成验证后将被 302 跳转到该地址;真实地址不会在门禁页展示
|
||||
</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">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.link-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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteLinkRecord,
|
||||
deleteRecord,
|
||||
linkRecordsExportUrl,
|
||||
listLinkRecords,
|
||||
listRecords,
|
||||
recordsExportUrl,
|
||||
} from '@/api';
|
||||
import type { DownloadRecord, DownloadResource } from '@/types';
|
||||
import type { RecordSubject } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
IconDeleteBin,
|
||||
@@ -18,18 +21,35 @@ import {
|
||||
VPagination,
|
||||
VSpace,
|
||||
} from '@halo-dev/components';
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
// 下载/链接记录取出的公共字段(时间统一为 time)
|
||||
interface RecordItem {
|
||||
metadata: {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: {
|
||||
email?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
resource: DownloadResource | null;
|
||||
resource: RecordSubject | null;
|
||||
kind?: 'download' | 'link';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const records = ref<DownloadRecord[]>([]);
|
||||
const isLink = computed(() => props.kind === 'link');
|
||||
|
||||
const records = ref<RecordItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const size = ref(20);
|
||||
@@ -68,15 +88,29 @@ async function fetchRecords() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listRecords({
|
||||
resourceSlug: props.resource.spec.slug,
|
||||
const slug = props.resource.spec.slug ?? '';
|
||||
const params = {
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
});
|
||||
records.value = (data.items ?? []).filter(
|
||||
(r) => !pendingDeletes.has(r.metadata.name)
|
||||
);
|
||||
total.value = data.total ?? 0;
|
||||
};
|
||||
const raw = isLink.value
|
||||
? await listLinkRecords({ ...params, linkSlug: slug })
|
||||
: await listRecords({ ...params, resourceSlug: slug });
|
||||
records.value = (raw.items ?? [])
|
||||
.filter((r) => !pendingDeletes.has(r.metadata.name))
|
||||
.map((r) => ({
|
||||
metadata: { name: r.metadata.name },
|
||||
spec: {
|
||||
email: r.spec.email,
|
||||
ip: r.spec.ip,
|
||||
userAgent: r.spec.userAgent,
|
||||
time:
|
||||
'visitedAt' in r.spec
|
||||
? (r.spec as { visitedAt?: string }).visitedAt
|
||||
: (r.spec as { downloadedAt?: string }).downloadedAt,
|
||||
},
|
||||
}));
|
||||
total.value = raw.total ?? 0;
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
@@ -86,19 +120,26 @@ async function fetchRecords() {
|
||||
|
||||
function handleExport() {
|
||||
// 直接导航下载(console 端点接受 session cookie)
|
||||
window.open(recordsExportUrl(props.resource?.spec.slug), '_blank');
|
||||
const url = isLink.value
|
||||
? linkRecordsExportUrl(props.resource?.spec.slug)
|
||||
: recordsExportUrl(props.resource?.spec.slug);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function handleDelete(record: DownloadRecord) {
|
||||
function handleDelete(record: RecordItem) {
|
||||
Dialog.warning({
|
||||
title: '删除下载记录',
|
||||
description: '确定要删除这条下载记录吗?删除后不可恢复。',
|
||||
title: isLink.value ? '删除访问记录' : '删除下载记录',
|
||||
description: '确定要删除这条记录吗?删除后不可恢复。',
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteRecord(record.metadata.name);
|
||||
if (isLink.value) {
|
||||
await deleteLinkRecord(record.metadata.name);
|
||||
} else {
|
||||
await deleteRecord(record.metadata.name);
|
||||
}
|
||||
// 删除当前页最后一条时回退一页
|
||||
if (records.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
@@ -131,7 +172,7 @@ watch(
|
||||
<template>
|
||||
<VModal
|
||||
:visible="visible"
|
||||
:title="`下载记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||
:title="`${isLink ? '访问' : '下载'}记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||
:width="960"
|
||||
mount-to-body
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@@ -163,8 +204,8 @@ watch(
|
||||
<VLoading v-if="loading" />
|
||||
<VEmpty
|
||||
v-else-if="records.length === 0"
|
||||
title="暂无下载记录"
|
||||
message="该资源还没有产生下载"
|
||||
:title="isLink ? '暂无访问记录' : '暂无下载记录'"
|
||||
:message="isLink ? '该链接还没有产生访问' : '该资源还没有产生下载'"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="records-table">
|
||||
@@ -179,7 +220,7 @@ watch(
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in records" :key="record.metadata.name">
|
||||
<td>{{ formatTime(record.spec.downloadedAt) }}</td>
|
||||
<td>{{ formatTime(record.spec.time) }}</td>
|
||||
<td>{{ record.spec.email || '匿名' }}</td>
|
||||
<td>{{ record.spec.ip || '-' }}</td>
|
||||
<td class="ua-cell" :title="record.spec.userAgent">
|
||||
|
||||
+21
-5
@@ -8,14 +8,14 @@ export default definePlugin({
|
||||
{
|
||||
parentName: 'Root',
|
||||
route: {
|
||||
path: '/download-manager',
|
||||
name: 'DownloadManager',
|
||||
component: () => import('@/views/DownloadManager.vue'),
|
||||
path: '/resource-manager',
|
||||
name: 'ResourceManager',
|
||||
component: () => import('@/views/ResourceManager.vue'),
|
||||
meta: {
|
||||
title: '下载管理',
|
||||
title: '资源访问管理',
|
||||
permissions: ['plugin:sharelink:view'],
|
||||
menu: {
|
||||
name: '下载管理',
|
||||
name: '资源访问管理',
|
||||
group: 'content',
|
||||
icon: markRaw(IconArrowDownCircleLine),
|
||||
priority: 52,
|
||||
@@ -23,6 +23,22 @@ export default definePlugin({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: 'Root',
|
||||
route: {
|
||||
path: '/download-manager',
|
||||
name: 'DownloadManagerRedirect',
|
||||
redirect: '/resource-manager',
|
||||
},
|
||||
},
|
||||
{
|
||||
parentName: 'Root',
|
||||
route: {
|
||||
path: '/link-manager',
|
||||
name: 'LinkManagerRedirect',
|
||||
redirect: '/resource-manager',
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {},
|
||||
});
|
||||
|
||||
@@ -33,6 +33,40 @@ export interface DownloadResourceList {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ProtectedLinkSpec {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
targetUrl?: string;
|
||||
requireEmailVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ProtectedLink {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
creationTimestamp?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: ProtectedLinkSpec;
|
||||
status?: {
|
||||
visitCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
stats?: {
|
||||
visitorCount?: number;
|
||||
referenceCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProtectedLinkList {
|
||||
items: ProtectedLink[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DownloadRecord {
|
||||
metadata: {
|
||||
name: string;
|
||||
@@ -47,6 +81,33 @@ export interface DownloadRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkVisitRecord {
|
||||
metadata: {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: {
|
||||
linkSlug: string;
|
||||
email?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
visitedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 记录抽屉(下载/链接共用)所需的最小资源形状
|
||||
export interface RecordSubject {
|
||||
metadata: {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: {
|
||||
displayName?: string;
|
||||
slug?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Halo 标准 ListResult 结构
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from '@halo-dev/components';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
|
||||
const resources = ref<DownloadResource[]>([]);
|
||||
const loading = ref(false);
|
||||
const references = ref<ReferencesMap>({});
|
||||
@@ -206,7 +210,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="下载管理" />
|
||||
<VPageHeader v-if="!embedded" title="下载管理" />
|
||||
|
||||
<div class="download-manager-page m-4">
|
||||
<VCard>
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteLink,
|
||||
getLinkReferences,
|
||||
listLinks,
|
||||
refreshLinkReferences,
|
||||
updateLink,
|
||||
} from '@/api';
|
||||
import LinkFormModal from '@/components/LinkFormModal.vue';
|
||||
import RecordsDrawer from '@/components/RecordsDrawer.vue';
|
||||
import type { ProtectedLink, 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 props = defineProps<{
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
|
||||
const links = ref<ProtectedLink[]>([]);
|
||||
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 editingLink = ref<ProtectedLink | null>(null);
|
||||
|
||||
const recordsVisible = ref(false);
|
||||
const recordsLink = ref<ProtectedLink | 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 visitUrl(slug: string): string {
|
||||
return `${window.location.origin}/link/${slug}`;
|
||||
}
|
||||
|
||||
async function copyVisitUrl(slug: string) {
|
||||
const url = visitUrl(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 fetchLinks() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listLinks();
|
||||
links.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 getLinkReferences()) ?? {};
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
referencesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshScan() {
|
||||
refreshingScan.value = true;
|
||||
try {
|
||||
references.value = (await refreshLinkReferences()) ?? {};
|
||||
Toast.success('引用扫描已刷新');
|
||||
fetchLinks();
|
||||
} 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() {
|
||||
editingLink.value = null;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(link: ProtectedLink) {
|
||||
editingLink.value = link;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleOpenRecords(link: ProtectedLink) {
|
||||
recordsLink.value = link;
|
||||
recordsVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(link: ProtectedLink, value: boolean) {
|
||||
const previous = link.spec.enabled;
|
||||
link.spec.enabled = value;
|
||||
try {
|
||||
await updateLink(link.metadata.name, {
|
||||
...link.spec,
|
||||
enabled: value,
|
||||
});
|
||||
Toast.success(value ? '已启用' : '已停用');
|
||||
} catch (e) {
|
||||
link.spec.enabled = previous;
|
||||
Toast.error(errorMessage(e));
|
||||
fetchLinks();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(link: ProtectedLink) {
|
||||
Dialog.warning({
|
||||
title: '删除受保护链接',
|
||||
description: `确定要删除链接「${link.spec.displayName}」吗?删除后访问链接 /link/${link.spec.slug} 将立即失效。`,
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLink(link.metadata.name);
|
||||
pendingDeletes.add(link.metadata.name);
|
||||
setTimeout(
|
||||
() => pendingDeletes.delete(link.metadata.name),
|
||||
3000
|
||||
);
|
||||
links.value = links.value.filter(
|
||||
(r) => r.metadata.name !== link.metadata.name
|
||||
);
|
||||
Toast.success(`已删除链接「${link.spec.displayName}」`);
|
||||
fetchLinks();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaved(link: ProtectedLink, created: boolean) {
|
||||
fetchLinks();
|
||||
if (created && link.spec.enabled) {
|
||||
Toast.success(`访问链接:/link/${link.spec.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLinks();
|
||||
fetchReferences();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader v-if="!embedded" title="链接访问管理" />
|
||||
|
||||
<div class="link-manager-page m-4">
|
||||
<VCard>
|
||||
<div class="card-toolbar">
|
||||
<div class="text-sm text-gray-500">
|
||||
共 {{ links.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="
|
||||
fetchLinks();
|
||||
fetchReferences();
|
||||
"
|
||||
>
|
||||
刷新
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="loading" />
|
||||
<VEmpty
|
||||
v-else-if="links.length === 0"
|
||||
title="暂无受保护链接"
|
||||
message="点击右上角「新建链接」为任意 http/https 地址创建访问门禁"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="link-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 links" :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">/link/{{ item.spec.slug }}</code>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
ghost
|
||||
@click="copyVisitUrl(item.spec.slug)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconClipboardLine />
|
||||
</template>
|
||||
复制
|
||||
</VButton>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
class="target-link"
|
||||
:href="item.spec.targetUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="item.spec.targetUrl"
|
||||
>
|
||||
{{ item.spec.targetUrl }}
|
||||
<IconExternalLinkLine />
|
||||
</a>
|
||||
</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?.visitCount ?? 0 }}</td>
|
||||
<td>{{ item.stats?.visitorCount ?? 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"
|
||||
>
|
||||
暂无文章引用该链接(可在编辑器中插入 /link/{{
|
||||
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>
|
||||
|
||||
<LinkFormModal
|
||||
v-model:visible="formModalVisible"
|
||||
:link="editingLink"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<RecordsDrawer
|
||||
v-model:visible="recordsVisible"
|
||||
kind="link"
|
||||
:resource="recordsLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.link-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link-table th,
|
||||
.link-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-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;
|
||||
}
|
||||
|
||||
.target-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.target-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { VPageHeader, VTabbar } from '@halo-dev/components';
|
||||
import DownloadManager from './DownloadManager.vue';
|
||||
import LinkManager from './LinkManager.vue';
|
||||
|
||||
type ResourceTab = 'download' | 'link';
|
||||
|
||||
const activeTab = ref<ResourceTab>('download');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="资源访问管理" />
|
||||
|
||||
<div class="resource-manager m-4">
|
||||
<VTabbar
|
||||
v-model:active-id="activeTab"
|
||||
:items="[
|
||||
{ id: 'download', label: '下载资源' },
|
||||
{ id: 'link', label: '受保护链接' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="resource-manager__body">
|
||||
<DownloadManager v-if="activeTab === 'download'" embedded />
|
||||
<LinkManager v-else embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resource-manager__body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user