Files
sharelink/ui/src/components/LinkFormModal.vue
T
shirainbown 54dbba9ea2 feat: 新增受保护链接并将控制台合并为资源访问管理
- 新增 ProtectedLink/LinkVisitRecord,/link/{slug} 门禁页 + 一次性令牌跳转
- 复用邮箱验证体系,验证码邮件区分下载/链接模板
- 控制台下载资源与受保护链接合并为「资源访问管理」单页(页签切换)
- 引用扫描支持 /link/ 链接,角色模板/设置/文档同步更新
- 版本 1.3.0
2026-08-09 14:28:06 +08:00

311 lines
7.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>