feat: 新增受保护链接并将控制台合并为资源访问管理
- 新增 ProtectedLink/LinkVisitRecord,/link/{slug} 门禁页 + 一次性令牌跳转
- 复用邮箱验证体系,验证码邮件区分下载/链接模板
- 控制台下载资源与受保护链接合并为「资源访问管理」单页(页签切换)
- 引用扫描支持 /link/ 链接,角色模板/设置/文档同步更新
- 版本 1.3.0
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user