feat: 新增受保护链接并将控制台合并为资源访问管理

- 新增 ProtectedLink/LinkVisitRecord,/link/{slug} 门禁页 + 一次性令牌跳转
- 复用邮箱验证体系,验证码邮件区分下载/链接模板
- 控制台下载资源与受保护链接合并为「资源访问管理」单页(页签切换)
- 引用扫描支持 /link/ 链接,角色模板/设置/文档同步更新
- 版本 1.3.0
This commit is contained in:
shirainbown
2026-08-09 14:28:06 +08:00
parent 1c8dad158f
commit 54dbba9ea2
37 changed files with 2659 additions and 223 deletions
+118
View File
@@ -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;
}
+310
View File
@@ -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>
+61 -20
View File
@@ -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
View File
@@ -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: {},
});
+61
View File
@@ -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[];
+5 -1
View File
@@ -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>
+534
View File
@@ -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>
+35
View File
@@ -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>