将初始化向导辅助说明改为可聚焦悬浮提示

This commit is contained in:
xuans 2026-09-20 23:25:11 +08:00
parent 68c42084f3
commit 43d313612b
9 changed files with 419 additions and 39 deletions

View File

@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js",
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"

View File

@ -1,4 +1,5 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
defineProps({
form: { type: Object, required: true },
testing: Boolean,
@ -32,8 +33,12 @@ const emit = defineEmits(["test"]);
</div>
</div>
<div class="setup-section-heading">
<strong>数据库存储</strong>
<span>选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。</span>
<strong>
数据库存储
<HelpTip>
选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。
</HelpTip>
</strong>
</div>
<div v-if="form.driver !== 'sqlite'" class="setup-grid two-columns database-connection-grid">
<label>

View File

@ -1,4 +1,5 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { useSetupWizard } from "./SetupWizard.js";
import AccessShell from "../components/AccessShell/AccessShell.vue";
import DatabaseSetup from "./DatabaseSetup.vue";
@ -142,8 +143,12 @@ const {
placeholder="例如 scert-admin"
/></label>
<label class="email-field"
><span>绑定邮箱</span
><div class="email-input-group">
><span>
绑定邮箱
<HelpTip>
可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。
</HelpTip>
</span><div class="email-input-group">
<input
ref="emailAutofillElement"
class="email-autofill-probe"
@ -188,7 +193,6 @@ const {
</option>
</select>
</div>
<small>可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。</small>
</label>
<label
><span>密码</span
@ -218,8 +222,12 @@ const {
/></label>
</div>
<div class="setup-section-heading">
<strong>服务访问配置</strong>
<span>先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。</span>
<strong>
服务访问配置
<HelpTip>
先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。
</HelpTip>
</strong>
</div>
<div class="setup-grid service-access-grid" :class="{ 'has-database': isBootstrap }">
<div class="access-target-field">
@ -264,7 +272,12 @@ const {
/>
</label>
<label>
<span>Scert 主服务端口</span>
<span>
Scert 主服务端口
<HelpTip>
管理页面和 API 对外提供服务使用的端口。
</HelpTip>
</span>
<input
id="server-port"
name="server_port"
@ -274,7 +287,6 @@ const {
max="65535"
type="number"
/>
<small>管理页面和 API 对外提供服务使用的端口。</small>
</label>
<DatabaseSetup
v-if="isBootstrap"
@ -300,8 +312,12 @@ const {
<div class="notification-layout">
<div class="notification-guide">
<strong>先完成基础配置</strong>
<span>填写邮件服务地址即可继续,其他项目已经提供合理默认值。</span>
<strong>
先完成基础配置
<HelpTip>
填写邮件服务地址即可继续,其他项目已经提供合理默认值。
</HelpTip>
</strong>
</div>
<div class="notification-row notification-service-row">
@ -312,8 +328,12 @@ const {
type="checkbox"
/>
<span>
<strong>启用 HTTP 邮件通知</strong>
<small>通过第三方邮件 API 发送任务通知。</small>
<strong>
启用 HTTP 邮件通知
<HelpTip>
通过第三方邮件 API 发送任务通知。
</HelpTip>
</strong>
</span>
</label>
<label class="confirm-row compact-row">
@ -323,8 +343,12 @@ const {
type="checkbox"
/>
<span>
<strong>启用 SMTP 通知</strong>
<small>通过 SMTP 出站客户端发送任务通知。</small>
<strong>
启用 SMTP 通知
<HelpTip>
通过 SMTP 出站客户端发送任务通知。
</HelpTip>
</strong>
</span>
</label>
</div>
@ -332,8 +356,12 @@ const {
<div v-if="form.email_smtp_enabled" class="notification-basic-panel smtp-basic-panel">
<div class="notification-section-heading">
<div>
<strong>SMTP 通知配置</strong>
<span>Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。</span>
<strong>
SMTP 通知配置
<HelpTip>
Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。
</HelpTip>
</strong>
</div>
<span class="notification-recommended-badge">推荐 STARTTLS</span>
</div>
@ -414,15 +442,24 @@ const {
<div v-if="form.email_http_enabled" class="notification-basic-panel">
<div class="notification-section-heading">
<div>
<strong>邮件通知基础配置</strong>
<span>填写服务地址、发件人和收件人即可,技术参数无需修改。</span>
<strong>
邮件通知基础配置
<HelpTip>
填写服务地址、发件人和收件人即可,技术参数无需修改。
</HelpTip>
</strong>
</div>
<span class="notification-recommended-badge">推荐配置</span>
</div>
<div class="notification-row notification-basic-row">
<label>
<span>邮件服务地址</span>
<span>
邮件服务地址
<HelpTip>
填写邮件服务提供的地址,不需要自行拼接接口路径。
</HelpTip>
</span>
<input
id="email-endpoint"
name="email_endpoint"
@ -430,17 +467,20 @@ const {
type="url"
placeholder="https://mail.example.com"
/>
<small>填写邮件服务提供的地址,不需要自行拼接接口路径。</small>
</label>
<label>
<span>邮件服务名称(可选)</span>
<span>
邮件服务名称(可选)
<HelpTip>
仅用于日志和通知内容中的识别。
</HelpTip>
</span>
<input
id="email-provider"
name="email_provider"
v-model.trim="form.email_provider"
placeholder="例如:Smail"
/>
<small>仅用于日志和通知内容中的识别。</small>
</label>
</div>
@ -463,14 +503,18 @@ const {
/>
</label>
<label>
<span>通知收件人</span>
<span>
通知收件人
<HelpTip>
续期、部署和错误通知将发送到这些地址。
</HelpTip>
</span>
<input
id="recipients"
name="recipients"
v-model="form.recipients"
placeholder="多个地址使用逗号分隔"
/>
<small>续期、部署和错误通知将发送到这些地址。</small>
</label>
</div>
</div>
@ -689,22 +733,29 @@ const {
</select></label
>
<label
><span>DNS 权限测试域名</span
><input
><span>
DNS 权限测试域名
<HelpTip>
使用域名时可填写管理页面域名;使用公网 IP
时必须另填一个由当前 DNS 账号管理的域名。
</HelpTip>
</span><input
id="dns-test-domain"
name="dns_test_domain"
v-model.trim="dnsTestDomain"
required
placeholder="例如 example.com"
/><small
>使用域名时可填写管理页面域名;使用公网 IP
时必须另填一个由当前 DNS 账号管理的域名。</small
></label
/></label
>
</div>
<div class="setup-grid two-columns propagation-grid">
<label>
<span>DNS 传播最大等待时间(秒)</span>
<span>
DNS 传播最大等待时间(秒)
<HelpTip>
默认 300 秒,最长允许等待 86400 秒。
</HelpTip>
</span>
<input
id="propagation-timeout-secs"
name="propagation_timeout_secs"
@ -714,10 +765,14 @@ const {
max="86400"
required
/>
<small>默认 300 秒,最长允许等待 86400 秒。</small>
</label>
<label>
<span>DNS 传播检测间隔(秒)</span>
<span>
DNS 传播检测间隔(秒)
<HelpTip>
默认 5 秒,不能大于最大等待时间。
</HelpTip>
</span>
<input
id="propagation-polling-interval-secs"
name="propagation_polling_interval_secs"
@ -727,7 +782,6 @@ const {
:max="form.propagation_timeout_secs"
required
/>
<small>默认 5 秒,不能大于最大等待时间。</small>
</label>
</div>
<p class="field-hint">
@ -935,8 +989,12 @@ const {
type="checkbox"
/>
<span>
<strong>初始化完成后开启管理页面</strong>
<small>桌面端可通过此开关决定是否启用浏览器管理页面。</small>
<strong>
初始化完成后开启管理页面
<HelpTip>
桌面端可通过此开关决定是否启用浏览器管理页面。
</HelpTip>
</strong>
</span>
</label>
</fieldset>

View File

@ -68,6 +68,14 @@
line-height: 1.35;
}
.access-shell--setup label:not(.confirm-row) > span,
.access-shell--setup .access-target-field > span {
display: flex;
align-items: center;
min-height: 20px;
line-height: 20px;
}
.access-shell--setup input:not([type="checkbox"]),
.access-shell--setup select {
height: 36px;
@ -149,7 +157,7 @@
width: 92px;
height: auto;
min-height: 36px;
margin-top: calc(8px + 1.35 * 12px);
margin-top: 28px;
white-space: normal;
overflow-wrap: anywhere;
padding: 6px 10px;

View File

@ -0,0 +1,57 @@
.help-tip-trigger {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 17px;
height: 17px;
margin-left: 6px;
border: 1px solid #7d91ae;
border-radius: 50%;
color: #526b8e;
background: #f4f7fc;
font-size: 11px;
font-weight: 700;
line-height: 1;
vertical-align: middle;
cursor: help;
user-select: none;
}
.help-tip-trigger:hover,
.help-tip-trigger:focus-visible {
border-color: #315faa;
color: #234f93;
background: #eaf1fc;
}
.help-tip-trigger:focus-visible {
outline: 2px solid #315faa;
outline-offset: 3px;
}
.help-tip-content {
position: fixed;
z-index: 10000;
display: block;
box-sizing: border-box;
width: max-content;
max-width: min(340px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
padding: 10px 12px;
border: 1px solid #334763;
border-radius: 8px;
color: #ffffff;
background: #25364f;
box-shadow: 0 6px 22px rgb(23 32 51 / 18%);
font-family: inherit;
font-size: 13px;
font-weight: 400;
line-height: 1.6;
letter-spacing: normal;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,77 @@
import { nextTick, onBeforeUnmount, onMounted, ref, useId } from "vue";
import { placeTooltip } from "./HelpTipPosition.js";
export function useHelpTip() {
const trigger = ref(null);
const tooltip = ref(null);
const visible = ref(false);
const position = ref({ left: "0px", top: "0px" });
const tooltipId = `help-tip-${useId()}`;
let timer;
function cancelHide() {
clearTimeout(timer);
}
function hide() {
cancelHide();
visible.value = false;
}
async function show() {
cancelHide();
visible.value = true;
await nextTick();
if (!visible.value || !trigger.value || !tooltip.value) return;
const point = placeTooltip(
trigger.value.getBoundingClientRect(),
tooltip.value.getBoundingClientRect(),
{ width: window.innerWidth, height: window.innerHeight },
);
position.value = { left: `${point.left}px`, top: `${point.top}px` };
}
function scheduleHide() {
cancelHide();
timer = setTimeout(() => {
if (document.activeElement !== trigger.value) hide();
}, 140);
}
function toggle() {
if (visible.value) hide();
else show();
}
function dismissOnEscape(event) {
if (event.key === "Escape") hide();
}
function dismissOutside(event) {
if (!trigger.value?.contains(event.target) && !tooltip.value?.contains(event.target)) hide();
}
function dismissOnScroll(event) {
if (!tooltip.value?.contains(event.target)) hide();
}
onMounted(() => {
document.addEventListener("keydown", dismissOnEscape);
document.addEventListener("pointerdown", dismissOutside);
window.addEventListener("resize", hide);
window.addEventListener("scroll", dismissOnScroll, true);
});
onBeforeUnmount(() => {
hide();
document.removeEventListener("keydown", dismissOnEscape);
document.removeEventListener("pointerdown", dismissOutside);
window.removeEventListener("resize", hide);
window.removeEventListener("scroll", dismissOnScroll, true);
});
return {
trigger, tooltip, tooltipId, visible, position,
show, hide, scheduleHide, cancelHide, toggle,
};
}

View File

@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import { placeTooltip } from "./HelpTipPosition.js";
import { useHelpTip } from "./HelpTip.js";
import { mountComposable } from "../../../test-support/mount.js";
const viewport = { width: 1366, height: 768 };
const popup = { width: 340, height: 70 };
test("tooltip stays below the trigger when space is available", () => {
assert.deepEqual(placeTooltip({ left: 100, top: 100, bottom: 117 }, popup, viewport), {
left: 100, top: 125,
});
});
test("tooltip flips above the trigger near the bottom edge", () => {
assert.deepEqual(placeTooltip({ left: 100, top: 720, bottom: 737 }, popup, viewport), {
left: 100, top: 642,
});
});
test("tooltip clamps at both horizontal edges", () => {
assert.equal(placeTooltip({ left: 1320, top: 100, bottom: 117 }, popup, viewport).left, 1014);
assert.equal(placeTooltip({ left: -10, top: 100, bottom: 117 }, popup, viewport).left, 12);
});
test("tooltip handles a viewport smaller than its measured content", () => {
assert.deepEqual(placeTooltip({ left: 0, top: 0, bottom: 17 }, popup, { width: 100, height: 50 }), {
left: 12, top: 12,
});
});
async function setup(t) {
const events = new Map();
function addEventListener(name, callback) {
events.set(name, callback);
}
function removeEventListener(name, callback) {
assert.equal(events.get(name), callback);
events.delete(name);
}
const mounted = await mountComposable(t, () => {
Object.assign(window, { ...viewport, innerWidth: viewport.width, innerHeight: viewport.height, addEventListener, removeEventListener });
Object.assign(document, { activeElement: null, addEventListener, removeEventListener });
return useHelpTip();
});
mounted.state.trigger.value = {
getBoundingClientRect: () => ({ left: 100, top: 100, bottom: 117 }),
contains: () => false,
};
mounted.state.tooltip.value = {
getBoundingClientRect: () => popup,
contains: () => false,
};
return { ...mounted, events };
}
test("tooltip opens, positions and closes on Escape", async (t) => {
const { state, events } = await setup(t);
assert.equal(state.visible.value, false);
await state.show();
assert.equal(state.visible.value, true);
assert.deepEqual(state.position.value, { left: "100px", top: "125px" });
events.get("keydown")({ key: "Escape" });
assert.equal(state.visible.value, false);
});
test("tooltip can be reached by pointer without closing", async (t) => {
const { state } = await setup(t);
await state.show();
state.scheduleHide();
state.cancelHide();
await new Promise(resolve => setTimeout(resolve, 180));
assert.equal(state.visible.value, true);
state.scheduleHide();
await new Promise(resolve => setTimeout(resolve, 180));
assert.equal(state.visible.value, false);
});
test("focused tooltip remains open on pointer leave", async (t) => {
const { state } = await setup(t);
document.activeElement = state.trigger.value;
await state.show();
state.scheduleHide();
await new Promise(resolve => setTimeout(resolve, 180));
assert.equal(state.visible.value, true);
state.hide();
assert.equal(state.visible.value, false);
});
test("outside click and viewport changes dismiss the tooltip", async (t) => {
const { state, events } = await setup(t);
for (const event of ["pointerdown", "resize", "scroll"]) {
await state.show();
events.get(event)({ target: {} });
assert.equal(state.visible.value, false);
}
});
test("unmount cancels pending work and removes all event listeners", async (t) => {
const { state, events, unmount } = await setup(t);
const pending = state.show();
state.scheduleHide();
unmount();
await pending;
assert.equal(state.visible.value, false);
assert.equal(events.size, 0);
});

View File

@ -0,0 +1,53 @@
<script setup>
import { useHelpTip } from "./HelpTip.js";
const {
trigger,
tooltip,
tooltipId,
visible,
position,
show,
hide,
scheduleHide,
cancelHide,
toggle,
} = useHelpTip();
</script>
<template>
<span
ref="trigger"
class="help-tip-trigger"
role="button"
tabindex="0"
:aria-labelledby="tooltipId"
:aria-expanded="visible"
@pointerenter="show"
@pointerleave="scheduleHide"
@focus="show"
@blur="hide"
@click.stop.prevent="toggle"
@keydown.enter.stop.prevent="toggle"
@keydown.space.stop.prevent="toggle"
@keydown.esc.stop.prevent="hide"
>
<span aria-hidden="true">?</span>
</span>
<Teleport to="body">
<span
v-show="visible"
:id="tooltipId"
ref="tooltip"
class="help-tip-content"
role="tooltip"
:style="position"
@pointerenter="cancelHide"
@pointerleave="scheduleHide"
>
<slot />
</span>
</Teleport>
</template>
<style scoped src="./HelpTip.css"></style>

View File

@ -0,0 +1,14 @@
export function placeTooltip(anchor, popup, viewport) {
const padding = 12;
const gap = 8;
const maxLeft = Math.max(padding, viewport.width - popup.width - padding);
const left = Math.max(padding, Math.min(anchor.left, maxLeft));
const below = anchor.bottom + gap;
const above = anchor.top - popup.height - gap;
const preferred = below + popup.height <= viewport.height - padding ? below : above;
const maxTop = Math.max(padding, viewport.height - popup.height - padding);
return {
left,
top: Math.max(padding, Math.min(preferred, maxTop)),
};
}