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