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
+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>