修复:将页面异步副作用绑定到当前归属
This commit is contained in:
1 parent
b3a66d97c8
commit
eca3aa8fe5
6 files changed
+623
-90
No files matched your search
@@ -11,6 +11,7 @@ definePageMeta({
|
|||||||
alias: [`/forgot_password`],
|
alias: [`/forgot_password`],
|
||||||
});
|
});
|
||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
|
import { onScopeDispose } from "vue";
|
||||||
import { normalizeClass } from "vue";
|
import { normalizeClass } from "vue";
|
||||||
import { createBlock } from "vue";
|
import { createBlock } from "vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
@@ -30,7 +31,7 @@ import { useRoute } from "#app";
|
|||||||
import { useRouter } from "#app";
|
import { useRouter } from "#app";
|
||||||
import { useHead } from "#app";
|
import { useHead } from "#app";
|
||||||
import { NuxtLink } from "#components";
|
import { NuxtLink } from "#components";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import button from "openvue/button";
|
import button from "openvue/button";
|
||||||
import message from "openvue/message";
|
import message from "openvue/message";
|
||||||
import { n as auth_redirect_n } from "~/lib/auth-redirect.js";
|
import { n as auth_redirect_n } from "~/lib/auth-redirect.js";
|
||||||
@@ -79,6 +80,12 @@ var D = {
|
|||||||
z = defineComponent({
|
z = defineComponent({
|
||||||
__name: `forgot-password`,
|
__name: `forgot-password`,
|
||||||
setup(f) {
|
setup(f) {
|
||||||
|
const session = useAuth().prepareLogin();
|
||||||
|
let disposed = false;
|
||||||
|
const isCurrent = () => !disposed && session.isCurrent();
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true;
|
||||||
|
});
|
||||||
(useHead({
|
(useHead({
|
||||||
title: `找回密码 - 破碎工坊网络`,
|
title: `找回密码 - 破碎工坊网络`,
|
||||||
}),
|
}),
|
||||||
@@ -133,7 +140,7 @@ var D = {
|
|||||||
}
|
}
|
||||||
// 校验账号、区号、验证码和两次密码后提交重置;失败交由验证码实例处理挑战刷新。
|
// 校验账号、区号、验证码和两次密码后提交重置;失败交由验证码实例处理挑战刷新。
|
||||||
async function pe() {
|
async function pe() {
|
||||||
if (G.value || Q.value) return;
|
if (!isCurrent() || G.value || Q.value) return;
|
||||||
W.value = ``;
|
W.value = ``;
|
||||||
let e = U.account.trim();
|
let e = U.account.trim();
|
||||||
if (!e)
|
if (!e)
|
||||||
@@ -145,26 +152,28 @@ var D = {
|
|||||||
return (W.value = `两次输入的密码不一致`);
|
return (W.value = `两次输入的密码不一致`);
|
||||||
G.value = !0;
|
G.value = !0;
|
||||||
try {
|
try {
|
||||||
(await z.post(`/account/password_reset`, {
|
await z.post(`/account/password_reset`, {
|
||||||
type: H.value,
|
type: H.value,
|
||||||
account: e,
|
account: e,
|
||||||
phone_code: `86`,
|
phone_code: `86`,
|
||||||
code: U.code.trim(),
|
code: U.code.trim(),
|
||||||
password: U.password,
|
password: U.password,
|
||||||
re_password: U.re_password,
|
re_password: U.re_password,
|
||||||
}),
|
});
|
||||||
await B.push({
|
if (!isCurrent()) return;
|
||||||
path: `/login`,
|
await B.push({
|
||||||
query: {
|
path: `/login`,
|
||||||
reset: `1`,
|
query: {
|
||||||
...auth_redirect_n(V.query.redirect),
|
reset: `1`,
|
||||||
},
|
...auth_redirect_n(V.query.redirect),
|
||||||
}));
|
},
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
((W.value = e instanceof Error ? e.message : `重置失败`),
|
((W.value = e instanceof Error ? e.message : `重置失败`),
|
||||||
await ue(e));
|
await ue(e));
|
||||||
} finally {
|
} finally {
|
||||||
G.value = !1;
|
if (isCurrent()) G.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 手机号/邮箱输入、验证码和密码表单共享提交锁;成功导航由 pe 负责,渲染层不直接建立会话。
|
// 手机号/邮箱输入、验证码和密码表单共享提交锁;成功导航由 pe 负责,渲染层不直接建立会话。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
import { onMounted } from "vue";
|
import { onMounted } from "vue";
|
||||||
|
import { onScopeDispose } from "vue";
|
||||||
import { normalizeClass } from "vue";
|
import { normalizeClass } from "vue";
|
||||||
import { createBlock } from "vue";
|
import { createBlock } from "vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
@@ -27,7 +28,7 @@ import { createVNode } from "vue";
|
|||||||
import { openBlock } from "vue";
|
import { openBlock } from "vue";
|
||||||
import { withModifiers } from "vue";
|
import { withModifiers } from "vue";
|
||||||
import { useRoute } from "#app";
|
import { useRoute } from "#app";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import dialog from "openvue/dialog";
|
import dialog from "openvue/dialog";
|
||||||
import button from "openvue/button";
|
import button from "openvue/button";
|
||||||
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
||||||
@@ -111,6 +112,12 @@ var ge = {
|
|||||||
defineComponent({
|
defineComponent({
|
||||||
__name: `index`,
|
__name: `index`,
|
||||||
setup(p) {
|
setup(p) {
|
||||||
|
const session = useAuth().prepareLogin();
|
||||||
|
let disposed = false;
|
||||||
|
const isCurrent = () => !disposed && session.isCurrent();
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true;
|
||||||
|
});
|
||||||
composables_t({
|
composables_t({
|
||||||
key: (e) => e.fullPath,
|
key: (e) => e.fullPath,
|
||||||
});
|
});
|
||||||
@@ -185,37 +192,39 @@ var ge = {
|
|||||||
}
|
}
|
||||||
// 将指定 ID 标为已读,完成后清除选择并刷新列表与全局未读计数。
|
// 将指定 ID 标为已读,完成后清除选择并刷新列表与全局未读计数。
|
||||||
async function ze(e) {
|
async function ze(e) {
|
||||||
if (!(!e.length || B.value || G.value)) {
|
if (isCurrent() && !(!e.length || B.value || G.value)) {
|
||||||
((B.value = !0), (U.value = ``));
|
((B.value = !0), (U.value = ``));
|
||||||
try {
|
try {
|
||||||
(await y.put(`/client_care/mail/read`, {
|
await y.put(`/client_care/mail/read`, {
|
||||||
id: e,
|
id: e,
|
||||||
}),
|
});
|
||||||
(z.value = []),
|
if (!isCurrent()) return;
|
||||||
await R(),
|
z.value = [];
|
||||||
await D());
|
await refreshMessages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `操作失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `操作失败`;
|
||||||
} finally {
|
} finally {
|
||||||
B.value = !1;
|
if (isCurrent()) B.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 使用 all 标记请求服务端全部已读,不局限于当前可见页。
|
// 使用 all 标记请求服务端全部已读,不局限于当前可见页。
|
||||||
async function Be() {
|
async function Be() {
|
||||||
if (!(B.value || G.value)) {
|
if (isCurrent() && !(B.value || G.value)) {
|
||||||
((B.value = !0), (U.value = ``));
|
((B.value = !0), (U.value = ``));
|
||||||
try {
|
try {
|
||||||
(await y.put(`/client_care/mail/read`, {
|
await y.put(`/client_care/mail/read`, {
|
||||||
all: 1,
|
all: 1,
|
||||||
}),
|
});
|
||||||
(z.value = []),
|
if (!isCurrent()) return;
|
||||||
await R(),
|
z.value = [];
|
||||||
await D());
|
await refreshMessages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `操作失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `操作失败`;
|
||||||
} finally {
|
} finally {
|
||||||
B.value = !1;
|
if (isCurrent()) B.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,24 +240,30 @@ var ge = {
|
|||||||
}
|
}
|
||||||
// 仅提交选中消息的 ID;成功后同步列表与未读计数。
|
// 仅提交选中消息的 ID;成功后同步列表与未读计数。
|
||||||
async function He() {
|
async function He() {
|
||||||
if (!z.value.length || G.value || B.value) return;
|
if (!isCurrent() || !z.value.length || G.value || B.value) return;
|
||||||
let e = z.value.map((e) => e.id);
|
let e = z.value.map((e) => e.id);
|
||||||
((G.value = !0), (U.value = ``));
|
((G.value = !0), (U.value = ``));
|
||||||
try {
|
try {
|
||||||
(await y.del(`/client_care/mail`, {
|
await y.del(`/client_care/mail`, {
|
||||||
id: e,
|
id: e,
|
||||||
}),
|
});
|
||||||
(W.value = !1),
|
if (!isCurrent()) return;
|
||||||
(z.value = []),
|
W.value = !1;
|
||||||
await R(),
|
z.value = [];
|
||||||
await D());
|
await refreshMessages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
((W.value = !1),
|
((W.value = !1),
|
||||||
(U.value = e instanceof Error ? e.message : `删除失败`));
|
(U.value = e instanceof Error ? e.message : `删除失败`));
|
||||||
} finally {
|
} finally {
|
||||||
G.value = !1;
|
if (isCurrent()) G.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function refreshMessages() {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
await R();
|
||||||
|
if (isCurrent()) await D();
|
||||||
|
}
|
||||||
let K = ref(!1),
|
let K = ref(!1),
|
||||||
q = ref(null),
|
q = ref(null),
|
||||||
J = ref(!1),
|
J = ref(!1),
|
||||||
@@ -257,7 +272,9 @@ var ge = {
|
|||||||
Z = 0;
|
Z = 0;
|
||||||
// 详情打开时清除旧正文并递增请求序号;加载成功后尝试标读,标读失败不覆盖已加载的正文。
|
// 详情打开时清除旧正文并递增请求序号;加载成功后尝试标读,标读失败不覆盖已加载的正文。
|
||||||
async function Q(e) {
|
async function Q(e) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
let t = ++Z;
|
let t = ++Z;
|
||||||
|
const ownsDetail = () => isCurrent() && t === Z;
|
||||||
((X.value = e),
|
((X.value = e),
|
||||||
(K.value = !0),
|
(K.value = !0),
|
||||||
(J.value = !0),
|
(J.value = !0),
|
||||||
@@ -265,7 +282,7 @@ var ge = {
|
|||||||
(q.value = null));
|
(q.value = null));
|
||||||
try {
|
try {
|
||||||
let n = await y.get(`/client_care/mail/${e}`);
|
let n = await y.get(`/client_care/mail/${e}`);
|
||||||
if (t !== Z) return;
|
if (!ownsDetail()) return;
|
||||||
if (!n?.mail?.id) {
|
if (!n?.mail?.id) {
|
||||||
Y.value = `消息不存在或已删除`;
|
Y.value = `消息不存在或已删除`;
|
||||||
return;
|
return;
|
||||||
@@ -274,23 +291,25 @@ var ge = {
|
|||||||
let r = M.value.find((t) => t.id === e);
|
let r = M.value.find((t) => t.id === e);
|
||||||
if (!r || r.read === 0)
|
if (!r || r.read === 0)
|
||||||
try {
|
try {
|
||||||
(await y.put(`/client_care/mail/read`, {
|
await y.put(`/client_care/mail/read`, {
|
||||||
id: [e],
|
id: [e],
|
||||||
}),
|
});
|
||||||
r && (r.read = 1),
|
if (!ownsDetail()) return;
|
||||||
await D());
|
if (r) r.read = 1;
|
||||||
|
await D();
|
||||||
} catch {}
|
} catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
t === Z && (Y.value = e instanceof Error ? e.message : `加载失败`);
|
ownsDetail() &&
|
||||||
|
(Y.value = e instanceof Error ? e.message : `加载失败`);
|
||||||
} finally {
|
} finally {
|
||||||
t === Z && (J.value = !1);
|
ownsDetail() && (J.value = !1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await D();
|
await D();
|
||||||
let e = Number(T.query.id);
|
let e = Number(T.query.id);
|
||||||
e && Q(e);
|
isCurrent() && e && Q(e);
|
||||||
}),
|
}),
|
||||||
// 批量操作基于当前选择集,详情正文经公共清理后展示;详情 ID 与加载代次防止快速切换串信。
|
// 批量操作基于当前选择集,详情正文经公共清理后展示;详情 ID 与加载代次防止快速切换串信。
|
||||||
(t, i) => {
|
(t, i) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reactive,
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
useId,
|
useId,
|
||||||
|
watch,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import Button from "openvue/button";
|
import Button from "openvue/button";
|
||||||
import Column from "openvue/column";
|
import Column from "openvue/column";
|
||||||
@@ -19,11 +20,12 @@ import Message from "openvue/message";
|
|||||||
import Paginator from "openvue/paginator";
|
import Paginator from "openvue/paginator";
|
||||||
import Textarea from "openvue/textarea";
|
import Textarea from "openvue/textarea";
|
||||||
import ListEmpty from "~/components/ListEmpty.vue";
|
import ListEmpty from "~/components/ListEmpty.vue";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import { n as loadCompleteList } from "~/lib/complete-list.js";
|
import { n as loadCompleteList } from "~/lib/complete-list.js";
|
||||||
import "~/assets/styles/ssh-keys.WWTOf2zG.css";
|
import "~/assets/styles/ssh-keys.WWTOf2zG.css";
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const session = useAuth().prepareLogin();
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
const draftSearch = ref("");
|
const draftSearch = ref("");
|
||||||
const appliedSearch = ref("");
|
const appliedSearch = ref("");
|
||||||
@@ -43,6 +45,16 @@ const copiedKeyId = ref(null);
|
|||||||
let copyResetTimer;
|
let copyResetTimer;
|
||||||
let requestGeneration = 0;
|
let requestGeneration = 0;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let editorGeneration = 0;
|
||||||
|
const isCurrent = () => !disposed && session.isCurrent();
|
||||||
|
watch(
|
||||||
|
editorVisible,
|
||||||
|
() => {
|
||||||
|
editorGeneration++;
|
||||||
|
saving.value = false;
|
||||||
|
},
|
||||||
|
{ flush: "sync" },
|
||||||
|
);
|
||||||
|
|
||||||
const firstRecord = computed(() => (currentPage.value - 1) * pageSize.value);
|
const firstRecord = computed(() => (currentPage.value - 1) * pageSize.value);
|
||||||
const visibleRows = computed(() =>
|
const visibleRows = computed(() =>
|
||||||
@@ -53,7 +65,7 @@ const visibleRows = computed(() =>
|
|||||||
|
|
||||||
// 仅允许当前加载代次且组件尚未卸载时修改列表反馈。
|
// 仅允许当前加载代次且组件尚未卸载时修改列表反馈。
|
||||||
function isCurrentLoad(generation) {
|
function isCurrentLoad(generation) {
|
||||||
return !disposed && generation === requestGeneration;
|
return isCurrent() && generation === requestGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除或搜索后夹紧页码;返回是否变化,供服务端分页决定是否重读修正后的页。
|
// 删除或搜索后夹紧页码;返回是否变化,供服务端分页决定是否重读修正后的页。
|
||||||
@@ -67,7 +79,7 @@ function clampPage() {
|
|||||||
|
|
||||||
// 非搜索模式请求当前服务端页,删除末页最后一条时先修正页码再重读。
|
// 非搜索模式请求当前服务端页,删除末页最后一条时先修正页码再重读。
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
if (appliedSearch.value) return loadSearchResults();
|
if (appliedSearch.value) return loadSearchResults();
|
||||||
const generation = ++requestGeneration;
|
const generation = ++requestGeneration;
|
||||||
searchLoading.value = false;
|
searchLoading.value = false;
|
||||||
@@ -174,7 +186,7 @@ function openEditDialog(key) {
|
|||||||
|
|
||||||
// 校验名称非空、字符数上限和公钥非空;按编辑对象选择创建/更新,成功后刷新当前模式列表。
|
// 校验名称非空、字符数上限和公钥非空;按编辑对象选择创建/更新,成功后刷新当前模式列表。
|
||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
if (saving.value || disposed) return;
|
if (saving.value || !isCurrent()) return;
|
||||||
actionError.value = "";
|
actionError.value = "";
|
||||||
if (!keyForm.name.trim()) {
|
if (!keyForm.name.trim()) {
|
||||||
actionError.value = "请输入密钥名称";
|
actionError.value = "请输入密钥名称";
|
||||||
@@ -189,6 +201,8 @@ async function saveKey() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
const generation = editorGeneration;
|
||||||
|
const ownsDraft = () => isCurrent() && generation === editorGeneration;
|
||||||
try {
|
try {
|
||||||
if (editingKey.value) {
|
if (editingKey.value) {
|
||||||
await api.put("/ssh_key/" + editingKey.value.id, {
|
await api.put("/ssh_key/" + editingKey.value.id, {
|
||||||
@@ -198,25 +212,25 @@ async function saveKey() {
|
|||||||
} else {
|
} else {
|
||||||
await api.post("/ssh_key", { ...keyForm });
|
await api.post("/ssh_key", { ...keyForm });
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
editorVisible.value = false;
|
if (ownsDraft()) editorVisible.value = false;
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed)
|
if (ownsDraft())
|
||||||
actionError.value = error instanceof Error ? error.message : "保存失败";
|
actionError.value = error instanceof Error ? error.message : "保存失败";
|
||||||
} finally {
|
} finally {
|
||||||
if (!disposed) saving.value = false;
|
if (ownsDraft()) saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除后复用加载逻辑;末页恢复由 loadKeys/clampPage 统一处理。
|
// 删除后复用加载逻辑;末页恢复由 loadKeys/clampPage 统一处理。
|
||||||
async function deleteKey(key) {
|
async function deleteKey(key) {
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
try {
|
try {
|
||||||
await api.del("/ssh_key/" + key.id);
|
await api.del("/ssh_key/" + key.id);
|
||||||
await loadKeys();
|
if (isCurrent()) await loadKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed)
|
if (isCurrent())
|
||||||
actionError.value = error instanceof Error ? error.message : "删除失败";
|
actionError.value = error instanceof Error ? error.message : "删除失败";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +239,7 @@ async function deleteKey(key) {
|
|||||||
async function copyPublicKey(key) {
|
async function copyPublicKey(key) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key.public_key);
|
await navigator.clipboard.writeText(key.public_key);
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
clearTimeout(copyResetTimer);
|
clearTimeout(copyResetTimer);
|
||||||
copiedKeyId.value = key.id;
|
copiedKeyId.value = key.id;
|
||||||
copyResetTimer = setTimeout(() => (copiedKeyId.value = null), 2000);
|
copyResetTimer = setTimeout(() => (copiedKeyId.value = null), 2000);
|
||||||
|
|||||||
+45
-24
@@ -12,6 +12,7 @@ definePageMeta({
|
|||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
import { renderList } from "vue";
|
import { renderList } from "vue";
|
||||||
import { onMounted } from "vue";
|
import { onMounted } from "vue";
|
||||||
|
import { onScopeDispose } from "vue";
|
||||||
import { normalizeClass } from "vue";
|
import { normalizeClass } from "vue";
|
||||||
import { createBlock } from "vue";
|
import { createBlock } from "vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
@@ -30,7 +31,7 @@ import { openBlock } from "vue";
|
|||||||
import { normalizeStyle } from "vue";
|
import { normalizeStyle } from "vue";
|
||||||
import { useRoute } from "#app";
|
import { useRoute } from "#app";
|
||||||
import { NuxtLink } from "#components";
|
import { NuxtLink } from "#components";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import button from "openvue/button";
|
import button from "openvue/button";
|
||||||
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
||||||
import message from "openvue/message";
|
import message from "openvue/message";
|
||||||
@@ -124,6 +125,12 @@ var le = {
|
|||||||
defineComponent({
|
defineComponent({
|
||||||
__name: `[id]`,
|
__name: `[id]`,
|
||||||
setup(m) {
|
setup(m) {
|
||||||
|
const session = useAuth().prepareLogin();
|
||||||
|
let disposed = false;
|
||||||
|
const isCurrent = () => !disposed && session.isCurrent();
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true;
|
||||||
|
});
|
||||||
let y = useRoute(),
|
let y = useRoute(),
|
||||||
z = useApi(),
|
z = useApi(),
|
||||||
B = String(y.params.id ?? y.query.id ?? ``);
|
B = String(y.params.id ?? y.query.id ?? ``);
|
||||||
@@ -143,17 +150,20 @@ var le = {
|
|||||||
const canOperate = computed(() => V.value?.can_operate === 1);
|
const canOperate = computed(() => V.value?.can_operate === 1);
|
||||||
// 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。
|
// 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。
|
||||||
async function X() {
|
async function X() {
|
||||||
|
if (!isCurrent()) return;
|
||||||
try {
|
try {
|
||||||
let e = await z.get(`/ticket/${B}`);
|
let e = await z.get(`/ticket/${B}`);
|
||||||
|
if (!isCurrent()) return;
|
||||||
if (!e.ticket || !e.ticket.id) {
|
if (!e.ticket || !e.ticket.id) {
|
||||||
U.value = `工单不存在或已删除`;
|
U.value = `工单不存在或已删除`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
V.value = e.ticket;
|
V.value = e.ticket;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `加载失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `加载失败`;
|
||||||
} finally {
|
} finally {
|
||||||
H.value = !1;
|
if (isCurrent()) H.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Z = computed(() => {
|
let Z = computed(() => {
|
||||||
@@ -192,15 +202,16 @@ var le = {
|
|||||||
);
|
);
|
||||||
// 工单允许操作时逐个上传并检查 save_name,成功部分保留在当前附件草稿。
|
// 工单允许操作时逐个上传并检查 save_name,成功部分保留在当前附件草稿。
|
||||||
async function xe(e) {
|
async function xe(e) {
|
||||||
if (J.value || G.value || !canOperate.value) return;
|
if (!isCurrent() || J.value || G.value || !canOperate.value) return;
|
||||||
let t = e.target,
|
let t = e.target,
|
||||||
n = Array.from(t.files ?? []);
|
n = Array.from(t.files ?? []);
|
||||||
if (n.length) {
|
if (n.length) {
|
||||||
((J.value = !0), (U.value = ``));
|
((J.value = !0), (U.value = ``));
|
||||||
try {
|
try {
|
||||||
for (let e of n) {
|
for (let e of n) {
|
||||||
|
if (!isCurrent() || !canOperate.value) return;
|
||||||
let t = await z.upload(e);
|
let t = await z.upload(e);
|
||||||
if (!canOperate.value) return;
|
if (!isCurrent() || !canOperate.value) return;
|
||||||
if (!t?.save_name)
|
if (!t?.save_name)
|
||||||
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
|
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
|
||||||
q.value.push({
|
q.value.push({
|
||||||
@@ -209,9 +220,10 @@ var le = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `附件上传失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `附件上传失败`;
|
||||||
} finally {
|
} finally {
|
||||||
((J.value = !1), (t.value = ``));
|
if (isCurrent()) ((J.value = !1), (t.value = ``));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,47 +266,56 @@ var le = {
|
|||||||
}
|
}
|
||||||
// 回复要求内容非空、附件已上传且服务端允许操作;成功后清空草稿与引用并重读工单。
|
// 回复要求内容非空、附件已上传且服务端允许操作;成功后清空草稿与引用并重读工单。
|
||||||
async function De() {
|
async function De() {
|
||||||
if (!(G.value || !W.value.trim() || J.value || !canOperate.value)) {
|
if (
|
||||||
|
isCurrent() &&
|
||||||
|
!(G.value || !W.value.trim() || J.value || !canOperate.value)
|
||||||
|
) {
|
||||||
G.value = !0;
|
G.value = !0;
|
||||||
try {
|
try {
|
||||||
(await z.post(`/ticket/${B}/reply`, {
|
await z.post(`/ticket/${B}/reply`, {
|
||||||
content: W.value.trim(),
|
content: W.value.trim(),
|
||||||
attachment: q.value.map((e) => e.save_name),
|
attachment: q.value.map((e) => e.save_name),
|
||||||
quote_reply_id: Y.value,
|
quote_reply_id: Y.value,
|
||||||
}),
|
});
|
||||||
(W.value = ``),
|
if (!isCurrent()) return;
|
||||||
(q.value = []),
|
W.value = ``;
|
||||||
(Y.value = 0),
|
q.value = [];
|
||||||
await X());
|
Y.value = 0;
|
||||||
|
await X();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `回复失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `回复失败`;
|
||||||
} finally {
|
} finally {
|
||||||
G.value = !1;
|
if (isCurrent()) G.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 催单后重新读取工单,结果与可继续操作状态以服务端为准。
|
// 催单后重新读取工单,结果与可继续操作状态以服务端为准。
|
||||||
async function Oe() {
|
async function Oe() {
|
||||||
if (!canOperate.value) return;
|
if (!isCurrent() || !canOperate.value) return;
|
||||||
K.value = !0;
|
K.value = !0;
|
||||||
try {
|
try {
|
||||||
(await z.put(`/ticket/${B}/urge`), await X());
|
await z.put(`/ticket/${B}/urge`);
|
||||||
|
if (isCurrent()) await X();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `催单失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `催单失败`;
|
||||||
} finally {
|
} finally {
|
||||||
K.value = !1;
|
if (isCurrent()) K.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。
|
// 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。
|
||||||
async function ke() {
|
async function ke() {
|
||||||
if (!canOperate.value) return;
|
if (!isCurrent() || !canOperate.value) return;
|
||||||
K.value = !0;
|
K.value = !0;
|
||||||
try {
|
try {
|
||||||
(await z.put(`/ticket/${B}/close`), await X());
|
await z.put(`/ticket/${B}/close`);
|
||||||
|
if (isCurrent()) await X();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
U.value = e instanceof Error ? e.message : `关闭失败`;
|
if (isCurrent())
|
||||||
|
U.value = e instanceof Error ? e.message : `关闭失败`;
|
||||||
} finally {
|
} finally {
|
||||||
K.value = !1;
|
if (isCurrent()) K.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import "~/assets/styles/create.gX6gpD00.css";
|
|||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
import { renderList } from "vue";
|
import { renderList } from "vue";
|
||||||
import { onMounted } from "vue";
|
import { onMounted } from "vue";
|
||||||
|
import { onScopeDispose } from "vue";
|
||||||
import { createBlock } from "vue";
|
import { createBlock } from "vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { withCtx } from "vue";
|
import { withCtx } from "vue";
|
||||||
@@ -24,7 +25,7 @@ import { openBlock } from "vue";
|
|||||||
import { withModifiers } from "vue";
|
import { withModifiers } from "vue";
|
||||||
import { useRouter } from "#app";
|
import { useRouter } from "#app";
|
||||||
import { NuxtLink } from "#components";
|
import { NuxtLink } from "#components";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import button from "openvue/button";
|
import button from "openvue/button";
|
||||||
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
||||||
import message from "openvue/message";
|
import message from "openvue/message";
|
||||||
@@ -100,6 +101,12 @@ var le = {
|
|||||||
defineComponent({
|
defineComponent({
|
||||||
__name: `create`,
|
__name: `create`,
|
||||||
setup(m) {
|
setup(m) {
|
||||||
|
const session = useAuth().prepareLogin();
|
||||||
|
let disposed = false;
|
||||||
|
const isCurrent = () => !disposed && session.isCurrent();
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true;
|
||||||
|
});
|
||||||
let b = useApi(),
|
let b = useApi(),
|
||||||
z = useRouter(),
|
z = useRouter(),
|
||||||
B = ref([]),
|
B = ref([]),
|
||||||
@@ -136,14 +143,16 @@ var le = {
|
|||||||
Z = ref(!1);
|
Z = ref(!1);
|
||||||
// 逐个上传并要求 save_name,上传部分成功的附件仍保留,失败显示具体文件问题。
|
// 逐个上传并要求 save_name,上传部分成功的附件仍保留,失败显示具体文件问题。
|
||||||
async function he(e) {
|
async function he(e) {
|
||||||
if (Z.value || W.value) return;
|
if (!isCurrent() || Z.value || W.value) return;
|
||||||
let t = e.target,
|
let t = e.target,
|
||||||
n = Array.from(t.files ?? []);
|
n = Array.from(t.files ?? []);
|
||||||
if (n.length) {
|
if (n.length) {
|
||||||
((Z.value = !0), (G.value = ``));
|
((Z.value = !0), (G.value = ``));
|
||||||
try {
|
try {
|
||||||
for (let e of n) {
|
for (let e of n) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
let t = await b.upload(e);
|
let t = await b.upload(e);
|
||||||
|
if (!isCurrent()) return;
|
||||||
if (!t?.save_name)
|
if (!t?.save_name)
|
||||||
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
|
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
|
||||||
X.value.push({
|
X.value.push({
|
||||||
@@ -152,9 +161,10 @@ var le = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
G.value = e instanceof Error ? e.message : `上传失败`;
|
if (isCurrent())
|
||||||
|
G.value = e instanceof Error ? e.message : `上传失败`;
|
||||||
} finally {
|
} finally {
|
||||||
((Z.value = !1), (t.value = ``));
|
if (isCurrent()) ((Z.value = !1), (t.value = ``));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,45 +174,50 @@ var le = {
|
|||||||
}
|
}
|
||||||
// 类型/部门加载成功后再开放提交;实例可选列表分页读取,最多 100 页,实例查询失败不阻止无关联工单创建。
|
// 类型/部门加载成功后再开放提交;实例可选列表分页读取,最多 100 页,实例查询失败不阻止无关联工单创建。
|
||||||
async function Q() {
|
async function Q() {
|
||||||
if (!K.value) {
|
if (isCurrent() && !K.value) {
|
||||||
((K.value = !0), (q.value = !1), (G.value = ``));
|
((K.value = !0), (q.value = !1), (G.value = ``));
|
||||||
try {
|
try {
|
||||||
let [e, t] = await Promise.all([
|
let [e, t] = await Promise.all([
|
||||||
b.get(`/ticket/type`),
|
b.get(`/ticket/type`),
|
||||||
b.get(`/ticket/department`),
|
b.get(`/ticket/department`),
|
||||||
]);
|
]);
|
||||||
|
if (!isCurrent()) return;
|
||||||
((B.value = e.list ?? []), (V.value = t.list ?? []));
|
((B.value = e.list ?? []), (V.value = t.list ?? []));
|
||||||
try {
|
try {
|
||||||
let e = [],
|
let e = [],
|
||||||
t = 1,
|
t = 1,
|
||||||
n = 0;
|
n = 0;
|
||||||
do {
|
do {
|
||||||
|
if (!isCurrent()) return;
|
||||||
let r = await b.get(`/host`, {
|
let r = await b.get(`/host`, {
|
||||||
page: t,
|
page: t,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
|
if (!isCurrent()) return;
|
||||||
(e.push(...(r.list ?? [])),
|
(e.push(...(r.list ?? [])),
|
||||||
(n = Number(r.count ?? e.length)),
|
(n = Number(r.count ?? e.length)),
|
||||||
(t += 1));
|
(t += 1));
|
||||||
} while (e.length < n && t <= 100);
|
} while (e.length < n && t <= 100);
|
||||||
H.value = e;
|
H.value = e;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!isCurrent()) return;
|
||||||
H.value = [];
|
H.value = [];
|
||||||
}
|
}
|
||||||
((U.ticket_type_id = B.value[0]?.id ?? null),
|
((U.ticket_type_id = B.value[0]?.id ?? null),
|
||||||
(U.admin_role_id = V.value[0]?.id ?? null),
|
(U.admin_role_id = V.value[0]?.id ?? null),
|
||||||
(q.value = !0));
|
(q.value = !0));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
G.value =
|
G.value =
|
||||||
e instanceof Error ? e.message : `加载工单选项失败,请重试`;
|
e instanceof Error ? e.message : `加载工单选项失败,请重试`;
|
||||||
} finally {
|
} finally {
|
||||||
K.value = !1;
|
if (isCurrent()) K.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 标题和内容非空且选项就绪后创建工单,只提交附件保存标识,成功进入返回 ID 的详情。
|
// 标题和内容非空且选项就绪后创建工单,只提交附件保存标识,成功进入返回 ID 的详情。
|
||||||
async function $() {
|
async function $() {
|
||||||
if (!(W.value || Z.value || K.value || !q.value)) {
|
if (isCurrent() && !(W.value || Z.value || K.value || !q.value)) {
|
||||||
if (((G.value = ``), !U.title.trim())) {
|
if (((G.value = ``), !U.title.trim())) {
|
||||||
G.value = `请输入工单标题`;
|
G.value = `请输入工单标题`;
|
||||||
return;
|
return;
|
||||||
@@ -221,11 +236,13 @@ var le = {
|
|||||||
host_ids: U.host_ids,
|
host_ids: U.host_ids,
|
||||||
attachment: X.value.map((e) => e.save_name),
|
attachment: X.value.map((e) => e.save_name),
|
||||||
});
|
});
|
||||||
|
if (!isCurrent()) return;
|
||||||
z.push(`/tickets/${e.id}`);
|
z.push(`/tickets/${e.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
G.value = e instanceof Error ? e.message : `提交失败`;
|
if (isCurrent())
|
||||||
|
G.value = e instanceof Error ? e.message : `提交失败`;
|
||||||
} finally {
|
} finally {
|
||||||
W.value = !1;
|
if (isCurrent()) W.value = !1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/** 页面/会话/草稿归属:真实页面与真实登录,只有外部 API 响应受控。 */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import {
|
||||||
|
appOrigin,
|
||||||
|
backendOrigin,
|
||||||
|
login,
|
||||||
|
loginAccount,
|
||||||
|
loginPassword,
|
||||||
|
scenario,
|
||||||
|
} from "./helpers/console-browser.mjs";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((done) => (resolve = done));
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(route, data, status = 200) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { status, msg: status === 200 ? "" : "本地延迟失败", data },
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boundary(page, handle) {
|
||||||
|
const requests = [];
|
||||||
|
await page
|
||||||
|
.context()
|
||||||
|
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||||
|
const entry = {
|
||||||
|
method: request.method(),
|
||||||
|
path,
|
||||||
|
authenticated: !!request.headers().authorization,
|
||||||
|
};
|
||||||
|
requests.push(entry);
|
||||||
|
if (await handle(route, path, entry, url)) return;
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseResponse(page, release, path) {
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(reply) => new URL(reply.url()).pathname === `/console/v1${path}`,
|
||||||
|
);
|
||||||
|
release.resolve();
|
||||||
|
await (await response).finished();
|
||||||
|
// Absence assertions observe a bounded idle window after the real fetch has completed.
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInOnCurrentDocument(page) {
|
||||||
|
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await page.waitForURL((url) => url.pathname !== "/login");
|
||||||
|
await page.getByRole("button", { name: "用户菜单", exact: true }).waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAndSignIn(page) {
|
||||||
|
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
||||||
|
await page.waitForURL((url) => url.pathname === "/login");
|
||||||
|
await signInOnCurrentDocument(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of ["current", "leave", "reauthenticate"]) {
|
||||||
|
test(
|
||||||
|
`message detail ownership after ${transition}`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const started = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
const requests = await boundary(page, async (route, path) => {
|
||||||
|
if (path === "/account/notification-preferences") {
|
||||||
|
await respond(route, {
|
||||||
|
email_enabled: false,
|
||||||
|
sms_enabled: false,
|
||||||
|
email_available: true,
|
||||||
|
sms_available: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/client_care/mail/list") {
|
||||||
|
await respond(route, { list: [], count: 0 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/client_care/mail/701") {
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
await respond(route, {
|
||||||
|
mail: {
|
||||||
|
id: 701,
|
||||||
|
title: "归属测试消息",
|
||||||
|
content: "<p>本地正文</p>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/client_care/mail/read") {
|
||||||
|
await respond(route, {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await login(page, "/messages?id=701");
|
||||||
|
await started.promise;
|
||||||
|
if (transition !== "current") await page.keyboard.press("Escape");
|
||||||
|
if (transition === "leave") {
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "会员中心导航" })
|
||||||
|
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||||
|
}
|
||||||
|
if (transition === "reauthenticate") await logoutAndSignIn(page);
|
||||||
|
await releaseResponse(page, release, "/client_care/mail/701");
|
||||||
|
const reads = requests.filter(
|
||||||
|
(entry) => entry.path === "/client_care/mail/read",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reads.length,
|
||||||
|
transition === "current" ? 1 : 0,
|
||||||
|
"Only the current detail owner may derive mark-read HTTP",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
transition,
|
||||||
|
markReadRequests: reads.length,
|
||||||
|
authenticated: reads.every((entry) => entry.authenticated),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const routePath of ["/tickets/create", "/tickets/71"]) {
|
||||||
|
for (const transition of ["current", "leave"]) {
|
||||||
|
test(
|
||||||
|
`ticket attachment queue ${routePath} after ${transition}`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const started = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
let uploads = 0;
|
||||||
|
const requests = await boundary(page, async (route, path) => {
|
||||||
|
if (path === "/ticket/config") {
|
||||||
|
await respond(route, { ticket_notice_open: 0 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||||
|
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/ticket/71") {
|
||||||
|
await respond(route, {
|
||||||
|
ticket: {
|
||||||
|
id: 71,
|
||||||
|
title: "本地工单",
|
||||||
|
content: "<p>本地内容</p>",
|
||||||
|
can_operate: 1,
|
||||||
|
attachment: [],
|
||||||
|
replies: [],
|
||||||
|
create_time: 1700000000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/upload") {
|
||||||
|
uploads += 1;
|
||||||
|
if (uploads === 1) {
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
}
|
||||||
|
await respond(route, {
|
||||||
|
save_name: `local-attachment-${uploads}.txt`,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await login(page, routePath);
|
||||||
|
const input = page.locator('input[type="file"]');
|
||||||
|
await input.setInputFiles(
|
||||||
|
[1, 2].map((id) => ({
|
||||||
|
name: `local-${id}.txt`,
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("non-sensitive local fixture"),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await started.promise;
|
||||||
|
if (transition === "leave") {
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "会员中心导航" })
|
||||||
|
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) => url.pathname === "/security/ssh-keys",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await releaseResponse(page, release, "/upload");
|
||||||
|
assert.equal(
|
||||||
|
uploads,
|
||||||
|
transition === "current" ? 2 : 1,
|
||||||
|
"A departed upload owner must not send the next attachment",
|
||||||
|
);
|
||||||
|
if (transition === "current")
|
||||||
|
await page.getByText("local-2.txt", { exact: true }).waitFor();
|
||||||
|
return {
|
||||||
|
routePath,
|
||||||
|
transition,
|
||||||
|
uploads,
|
||||||
|
authenticated: requests
|
||||||
|
.filter((entry) => entry.path === "/upload")
|
||||||
|
.every((entry) => entry.authenticated),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of ["current", "leave"]) {
|
||||||
|
test(
|
||||||
|
`password reset navigation after ${transition}`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const started = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
await boundary(page, async (route, path) => {
|
||||||
|
if (path !== "/account/password_reset") return false;
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
await respond(route, {});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await page.goto(`${appOrigin}/forgot-password`);
|
||||||
|
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||||
|
await page.getByLabel("验证码 *", { exact: true }).fill("123456");
|
||||||
|
await page
|
||||||
|
.getByLabel("新密码 *", { exact: true })
|
||||||
|
.fill("local-test-password");
|
||||||
|
await page
|
||||||
|
.getByLabel("确认新密码 *", { exact: true })
|
||||||
|
.fill("local-test-password");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "重置密码", exact: true })
|
||||||
|
.click();
|
||||||
|
await started.promise;
|
||||||
|
if (transition === "leave") {
|
||||||
|
await page
|
||||||
|
.getByRole("link", { name: "想起密码了?返回登录", exact: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("link", { name: "注册账号", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL((url) => url.pathname === "/register");
|
||||||
|
}
|
||||||
|
await releaseResponse(page, release, "/account/password_reset");
|
||||||
|
assert.equal(
|
||||||
|
new URL(page.url()).pathname,
|
||||||
|
transition === "current" ? "/login" : "/register",
|
||||||
|
"Only the current reset operation may navigate",
|
||||||
|
);
|
||||||
|
return { transition, finalRoute: new URL(page.url()).pathname };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const action of ["options", "submit", "reply", "urge", "close"]) {
|
||||||
|
test(
|
||||||
|
`ticket ${action} cannot derive work after leaving`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const started = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
const detail = ["reply", "urge", "close"].includes(action);
|
||||||
|
const heldPath =
|
||||||
|
action === "options"
|
||||||
|
? "/host"
|
||||||
|
: action === "submit"
|
||||||
|
? "/ticket"
|
||||||
|
: `/ticket/71/${action}`;
|
||||||
|
let hostPages = 0;
|
||||||
|
let detailReads = 0;
|
||||||
|
await boundary(page, async (route, path, entry) => {
|
||||||
|
if (path === "/ticket/config") {
|
||||||
|
await respond(route, { ticket_notice_open: 0 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||||
|
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path === "/ticket/71" && entry.method === "GET") {
|
||||||
|
detailReads += 1;
|
||||||
|
await respond(route, {
|
||||||
|
ticket: {
|
||||||
|
id: 71,
|
||||||
|
title: "本地工单",
|
||||||
|
content: "<p>本地内容</p>",
|
||||||
|
can_operate: 1,
|
||||||
|
attachment: [],
|
||||||
|
replies: [],
|
||||||
|
create_time: 1700000000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path !== heldPath) return false;
|
||||||
|
if (action === "options") hostPages += 1;
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
await respond(
|
||||||
|
route,
|
||||||
|
action === "options"
|
||||||
|
? { list: [{ id: 1, name: "本地实例" }], count: 2 }
|
||||||
|
: { id: 71 },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await login(page, detail ? "/tickets/71" : "/tickets/create");
|
||||||
|
if (action === "submit") {
|
||||||
|
await page
|
||||||
|
.getByPlaceholder("输入工单标题", { exact: true })
|
||||||
|
.fill("本地测试标题");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "工单内容", exact: true })
|
||||||
|
.fill("本地测试内容");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "创建工单", exact: true })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
if (detail) {
|
||||||
|
await page
|
||||||
|
.getByRole("heading", { name: "本地工单", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
if (action === "reply")
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "回复内容", exact: true })
|
||||||
|
.fill("本地测试回复");
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: { reply: "提交回复", urge: "催单", close: "关闭工单" }[
|
||||||
|
action
|
||||||
|
],
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
await started.promise;
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "会员中心导航" })
|
||||||
|
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||||
|
await releaseResponse(page, release, heldPath);
|
||||||
|
assert.equal(
|
||||||
|
new URL(page.url()).pathname,
|
||||||
|
"/security/ssh-keys",
|
||||||
|
"Old ticket completion must not navigate the current page",
|
||||||
|
);
|
||||||
|
if (action === "options")
|
||||||
|
assert.equal(
|
||||||
|
hostPages,
|
||||||
|
1,
|
||||||
|
"Departed options loader cannot fetch the next host page",
|
||||||
|
);
|
||||||
|
if (detail)
|
||||||
|
assert.equal(
|
||||||
|
detailReads,
|
||||||
|
1,
|
||||||
|
"Departed ticket write cannot derive a detail reload",
|
||||||
|
);
|
||||||
|
return { action, currentRoutePreserved: true, hostPages, detailReads };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const outcome of ["success", "error"]) {
|
||||||
|
test(
|
||||||
|
`SSH save ${outcome} cannot own a reopened editor`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
(t) =>
|
||||||
|
scenario(t, {}, async (page) => {
|
||||||
|
const started = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
let saves = 0;
|
||||||
|
await boundary(page, async (route, path, entry) => {
|
||||||
|
if (path !== "/ssh_key" || entry.method !== "POST") return false;
|
||||||
|
saves += 1;
|
||||||
|
if (saves === 1) {
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
}
|
||||||
|
await respond(
|
||||||
|
route,
|
||||||
|
{},
|
||||||
|
saves === 1 && outcome === "error" ? 422 : 200,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "添加密钥", exact: true })
|
||||||
|
.click();
|
||||||
|
let editor = page.getByRole("dialog", {
|
||||||
|
name: "添加密钥",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await editor.getByLabel("名称 *", { exact: true }).fill("old-draft");
|
||||||
|
await editor
|
||||||
|
.getByLabel("公钥内容 *", { exact: true })
|
||||||
|
.fill("ssh-ed25519 local-old-public-key");
|
||||||
|
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||||
|
await started.promise;
|
||||||
|
await editor.getByRole("button", { name: "取消", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "添加密钥", exact: true })
|
||||||
|
.click();
|
||||||
|
editor = page.getByRole("dialog", { name: "添加密钥", exact: true });
|
||||||
|
await editor.getByLabel("名称 *", { exact: true }).fill("new-draft");
|
||||||
|
await editor
|
||||||
|
.getByLabel("公钥内容 *", { exact: true })
|
||||||
|
.fill("ssh-ed25519 local-new-public-key");
|
||||||
|
await releaseResponse(page, release, "/ssh_key");
|
||||||
|
assert.equal(
|
||||||
|
await editor.isVisible(),
|
||||||
|
true,
|
||||||
|
"Old completion cannot close the new editor",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await editor.getByLabel("名称 *", { exact: true }).inputValue(),
|
||||||
|
"new-draft",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await editor.getByRole("alert").count(),
|
||||||
|
0,
|
||||||
|
"Old failure cannot become the new draft's error",
|
||||||
|
);
|
||||||
|
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||||
|
await editor.waitFor({ state: "hidden" });
|
||||||
|
assert.equal(saves, 2, "The new editor remains saveable");
|
||||||
|
return { outcome, newerDraftPreserved: true, saves };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user