完成50%
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
@keydown.esc.stop="cancel"
|
||||
@keydown.esc.stop.prevent="cancel"
|
||||
@keydown.tab="trapFocus"
|
||||
>
|
||||
<scroll-view class="app-dialog__content" scroll-y>
|
||||
<view class="app-dialog__copy">
|
||||
@@ -62,6 +63,38 @@ const emit = defineEmits(["confirm", "cancel", "close"]);
|
||||
const dialogRef = ref(null);
|
||||
let previousFocus = null;
|
||||
|
||||
const getDialogElement = () => {
|
||||
const target = dialogRef.value;
|
||||
if (target && typeof target.querySelectorAll === "function") return target;
|
||||
if (target?.$el && typeof target.$el.querySelectorAll === "function") return target.$el;
|
||||
return null;
|
||||
};
|
||||
|
||||
const trapFocus = (event) => {
|
||||
if (typeof document === "undefined") return;
|
||||
const dialog = getDialogElement() || event.currentTarget;
|
||||
if (!dialog || typeof dialog.querySelectorAll !== "function") return;
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll(
|
||||
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => element.offsetParent !== null);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus?.();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
@@ -69,7 +102,7 @@ watch(
|
||||
if (visible) {
|
||||
previousFocus = document.activeElement;
|
||||
await nextTick();
|
||||
dialogRef.value?.focus?.();
|
||||
getDialogElement()?.focus?.();
|
||||
return;
|
||||
}
|
||||
previousFocus?.focus?.();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 公共组件:根页面底部导航;仅维护家谱、家族、我的三项已确认入口及其透明图标。 -->
|
||||
<template>
|
||||
<view class="app-tabbar" role="tablist" aria-label="主要导航">
|
||||
<view
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="tab-item"
|
||||
@@ -9,28 +9,31 @@
|
||||
:aria-selected="active === item.key"
|
||||
:aria-label="`${item.label}${active === item.key ? ',当前页面' : ''}`"
|
||||
hover-class="tab-item--pressed"
|
||||
@click="switchTab(item)"
|
||||
@click="switchRoot(item)"
|
||||
>
|
||||
<image
|
||||
class="tab-icon"
|
||||
:src="active === item.key ? item.activeIcon : item.icon"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<text :class="['tab-label', { active: active === item.key }]">{{
|
||||
item.label
|
||||
}}</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goRoot } from "@/utils/navigation.js";
|
||||
|
||||
const props = defineProps({ active: { type: String, required: true } });
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "genealogy",
|
||||
label: "家谱",
|
||||
path: "/pages/genealogy/g01-my-genealogies",
|
||||
routeKey: "G01",
|
||||
icon: "/static/assets/foundation/transparent/tab-genealogy.png",
|
||||
activeIcon:
|
||||
"/static/assets/foundation/transparent/tab-genealogy-active.png",
|
||||
@@ -38,21 +41,22 @@ const items = [
|
||||
{
|
||||
key: "family",
|
||||
label: "家族",
|
||||
path: "/pages/family/f01-family-feed",
|
||||
routeKey: "F01",
|
||||
icon: "/static/assets/foundation/transparent/tab-family.png",
|
||||
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
|
||||
},
|
||||
{
|
||||
key: "profile",
|
||||
label: "我的",
|
||||
path: "/pages/profile/m01-profile-home",
|
||||
routeKey: "M01",
|
||||
icon: "/static/assets/foundation/transparent/tab-profile.png",
|
||||
activeIcon: "/static/assets/foundation/transparent/tab-profile-active.png",
|
||||
},
|
||||
];
|
||||
|
||||
const switchTab = (item) => {
|
||||
if (item.key !== props.active) uni.reLaunch({ url: item.path });
|
||||
const switchRoot = (item) => {
|
||||
if (item.key === props.active) return;
|
||||
return goRoot(item.routeKey);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -78,8 +82,16 @@ const switchTab = (item) => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 4rpx 0;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
}
|
||||
.tab-item::after {
|
||||
border: 0;
|
||||
}
|
||||
.tab-item--pressed {
|
||||
opacity: 0.72;
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
<!-- 公共组件:F/R/N/M 普通任务页母版;业务结构按类型区分,视觉继承各模块基准页。 -->
|
||||
<template>
|
||||
<view
|
||||
class="module-page"
|
||||
:style="moduleAssetStyle"
|
||||
:class="[
|
||||
`module-page--${moduleKey}`,
|
||||
`module-page--${page.template}`,
|
||||
`module-state--${moduleState}`,
|
||||
]"
|
||||
>
|
||||
<ModulePageBackground :module="moduleKey" />
|
||||
<view class="module-page__header"><PageHeader :title="page.title" /></view>
|
||||
|
||||
<view v-if="moduleState === 'loading'" class="module-page__loading">
|
||||
<AppLoading
|
||||
:text="`正在整理${page.title}`"
|
||||
description="请稍候,内容正在归卷。"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="moduleState === 'ready'" class="module-page__content">
|
||||
<view class="module-lead">
|
||||
<text>{{ page.subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="page.template === 'form'" class="module-panel module-form">
|
||||
<text class="section-eyebrow">填写信息</text>
|
||||
<view v-for="field in page.fields" :key="field" class="form-row">
|
||||
<text>{{ field }}</text>
|
||||
<input
|
||||
:placeholder="`请输入${field}`"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="page.note" class="form-note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'list'" class="content-stack">
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="list-card"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<view class="list-card__copy">
|
||||
<text>{{ item[0] }}</text>
|
||||
<text>{{ item[1] }}</text>
|
||||
<text>查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'detail'"
|
||||
class="module-panel module-detail"
|
||||
>
|
||||
<text class="section-eyebrow">档案详情</text>
|
||||
<view v-for="item in page.sections" :key="item[0]" class="detail-card">
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'timeline'"
|
||||
class="content-stack timeline-stack"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in page.sections"
|
||||
:key="item[0]"
|
||||
class="timeline-row"
|
||||
>
|
||||
<view
|
||||
><text>第 {{ index + 1 }} 则</text><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'settings'"
|
||||
class="module-panel module-settings"
|
||||
>
|
||||
<text class="section-eyebrow">服务与设置</text>
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="settings-row"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ normalizedSectionCopy(item[1]) }}</text></view
|
||||
>
|
||||
<text>查看</text>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else class="module-panel status-card">
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text>
|
||||
<text class="status-card__lead">{{ page.lead }}</text>
|
||||
<text class="status-card__note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="module-page__content module-page__content--state">
|
||||
<view class="module-panel status-card">
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text>
|
||||
<text class="status-card__lead">{{ stateCopy.title }}</text>
|
||||
<text class="status-card__note">{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:type="moduleState === 'error' ? 'secondary' : 'primary'"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppTabbar v-if="page.tab" :active="page.tab" />
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import { pageCatalog } from "@/data/page-catalog.js";
|
||||
|
||||
const props = defineProps({ pageId: { type: String, required: true } });
|
||||
const page = computed(() => pageCatalog[props.pageId]);
|
||||
const moduleKey = computed(
|
||||
() =>
|
||||
({ f: "family", r: "records", n: "notification", m: "profile" })[
|
||||
props.pageId[0]
|
||||
] || "profile",
|
||||
);
|
||||
const assetModule = computed(
|
||||
() =>
|
||||
({
|
||||
family: "family",
|
||||
records: "records",
|
||||
notification: "notification",
|
||||
profile: "profile",
|
||||
})[moduleKey.value],
|
||||
);
|
||||
const contentAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-content-frame.png`,
|
||||
);
|
||||
const fieldAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-field-frame.png`,
|
||||
);
|
||||
const moduleAssetStyle = computed(() => ({
|
||||
"--module-content-asset": `url(${contentAsset.value})`,
|
||||
"--module-field-asset": `url(${fieldAsset.value})`,
|
||||
}));
|
||||
const query = (() => {
|
||||
if (typeof location !== "undefined")
|
||||
return Object.fromEntries(
|
||||
new URLSearchParams(location.hash.split("?")[1] || ""),
|
||||
);
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.options || {};
|
||||
})();
|
||||
const moduleState = ref(
|
||||
["loading", "empty", "success", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "ready",
|
||||
);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let toastTimer = null;
|
||||
const normalizedNote = computed(() =>
|
||||
(page.value.note || "").replace(
|
||||
/待接入|服务待接入|功能待接入/g,
|
||||
"将在后续功能阶段开放",
|
||||
),
|
||||
);
|
||||
const normalizedSectionCopy = (copy) =>
|
||||
String(copy || "").replace(/待接入|功能待接入/g, "后续功能阶段开放");
|
||||
const stateCopy = computed(() => {
|
||||
const pageState = page.value.states?.[moduleState.value];
|
||||
if (pageState) return pageState;
|
||||
return (
|
||||
{
|
||||
empty: {
|
||||
eyebrow: "暂无内容",
|
||||
title: `还没有${page.value.title}记录`,
|
||||
copy: "完成第一条记录后,内容会按时间或类别整理在这里。",
|
||||
action: page.value.action,
|
||||
},
|
||||
success: {
|
||||
eyebrow: "操作结果",
|
||||
title: `${page.value.title}内容已更新`,
|
||||
copy: "当前页面已展示操作结果;正式数据将在接口阶段接入。",
|
||||
action: "继续查看",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "内容暂不可用",
|
||||
title: `暂时无法打开${page.value.title}`,
|
||||
copy: "请稍后重新进入,现有资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
}[moduleState.value] || {}
|
||||
);
|
||||
});
|
||||
const handleStateAction = () => {
|
||||
if (stateCopy.value.actionMode === "back") {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
moduleState.value = "ready";
|
||||
};
|
||||
const completeAction = () => {
|
||||
moduleState.value = "success";
|
||||
};
|
||||
const openPreview = (item) => {
|
||||
toastMessage.value = item[0];
|
||||
toastVisible.value = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
toastTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.module-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.module-page__header,
|
||||
.module-page__content,
|
||||
.module-page__loading {
|
||||
z-index: 1;
|
||||
}
|
||||
.module-page__loading {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.module-page__content {
|
||||
padding: 16rpx 26rpx 56rpx;
|
||||
}
|
||||
.module-lead {
|
||||
display: flex;
|
||||
min-height: 56rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 12rpx 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% auto no-repeat;
|
||||
}
|
||||
.module-lead text {
|
||||
display: block;
|
||||
}
|
||||
.module-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.section-eyebrow {
|
||||
display: block;
|
||||
margin: 2rpx 8rpx 14rpx;
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 84rpx;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 26rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-row,
|
||||
.settings-row {
|
||||
@include adaptive.adaptive-module-field;
|
||||
}
|
||||
.form-row > text {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-row input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 56rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.form-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
.form-note {
|
||||
display: block;
|
||||
margin: 18rpx 18rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
.module-form > .app-button,
|
||||
.module-detail > .app-button,
|
||||
.module-settings > .app-button,
|
||||
.content-stack > .app-button {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.content-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.list-card,
|
||||
.timeline-row {
|
||||
width: 100%;
|
||||
min-height: 188rpx;
|
||||
}
|
||||
.list-card,
|
||||
.detail-card,
|
||||
.timeline-row,
|
||||
.status-card {
|
||||
@include adaptive.adaptive-module-content;
|
||||
}
|
||||
.list-card__copy {
|
||||
padding: 34rpx 46rpx 28rpx;
|
||||
}
|
||||
.list-card__copy text {
|
||||
display: block;
|
||||
}
|
||||
.list-card__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list-card__copy text:nth-child(2) {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.list-card__copy text:last-child {
|
||||
margin-top: 11rpx;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-card {
|
||||
min-height: 170rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.detail-card > view {
|
||||
padding: 34rpx 44rpx;
|
||||
}
|
||||
.detail-card text,
|
||||
.settings-row text {
|
||||
display: block;
|
||||
}
|
||||
.detail-card text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-card text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.timeline-row > view {
|
||||
padding: 29rpx 44rpx;
|
||||
}
|
||||
.timeline-row text {
|
||||
display: block;
|
||||
}
|
||||
.timeline-row text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.timeline-row text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-row text:last-child {
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 94rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 20rpx 28rpx 18rpx 26rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.settings-row > view text:first-child {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-row > view text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.settings-row > text {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-card {
|
||||
min-height: 330rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.status-card__body {
|
||||
padding: 70rpx 60rpx 48rpx;
|
||||
}
|
||||
.status-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.status-card__lead {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status-card__note {
|
||||
display: block;
|
||||
margin-top: 17rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.status-card .app-button {
|
||||
margin: 28rpx auto 0;
|
||||
}
|
||||
.module-page__content--state {
|
||||
padding-top: 72rpx;
|
||||
}
|
||||
.module-page--family .section-eyebrow {
|
||||
color: #9f2c23;
|
||||
}
|
||||
.module-page--records .section-eyebrow {
|
||||
color: #8c261f;
|
||||
}
|
||||
.module-page--notification .section-eyebrow {
|
||||
color: #9b2b21;
|
||||
}
|
||||
.module-page--profile .section-eyebrow {
|
||||
color: #a22d24;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.module-page__content {
|
||||
padding-right: 34rpx;
|
||||
padding-left: 34rpx;
|
||||
}
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.module-page__content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.form-row {
|
||||
gap: 14rpx;
|
||||
}
|
||||
.status-card__body {
|
||||
padding-right: 44rpx;
|
||||
padding-left: 44rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+54
-23
@@ -8,37 +8,40 @@
|
||||
class="header-texture"
|
||||
src="/static/assets/foundation/opaque/root-header-cinnabar.jpg"
|
||||
mode="scaleToFill"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<image
|
||||
v-if="root"
|
||||
class="header-hall"
|
||||
src="/static/assets/foundation/transparent/root-header-hall.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view class="header-side header-side--left">
|
||||
<button
|
||||
<view
|
||||
v-if="root"
|
||||
class="header-icon-button"
|
||||
aria-label="返回首页"
|
||||
@click="$emit('brand')"
|
||||
class="header-brand"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<image
|
||||
class="header-logo"
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
<button
|
||||
v-else
|
||||
class="header-back"
|
||||
aria-label="返回上一页"
|
||||
hover-class="header-back--pressed"
|
||||
@click="goBack"
|
||||
@click="handleBack"
|
||||
>
|
||||
<image
|
||||
class="header-back__icon"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
@@ -47,49 +50,57 @@
|
||||
|
||||
<view class="header-side header-side--right">
|
||||
<button
|
||||
v-if="root"
|
||||
v-if="root && notice"
|
||||
class="header-icon-button header-notice"
|
||||
aria-label="打开消息中心"
|
||||
:aria-label="unreadCount > 0 ? `打开消息中心,${unreadCount} 条未读` : '打开消息中心'"
|
||||
@click="$emit('notice')"
|
||||
>
|
||||
<image
|
||||
class="header-notice-icon"
|
||||
src="/static/assets/foundation/transparent/notice.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<view v-if="unreadCount > 0" class="notice-dot"></view>
|
||||
</button>
|
||||
<button v-else class="header-action" :disabled="!action" @click="$emit('action')">{{
|
||||
<button
|
||||
v-else-if="root && action"
|
||||
class="header-action header-action--root"
|
||||
@click="$emit('action')"
|
||||
>{{ action }}</button>
|
||||
<button v-else-if="action" class="header-action" @click="$emit('action')">{{
|
||||
action
|
||||
}}</button>
|
||||
<view
|
||||
v-else
|
||||
class="header-action-placeholder"
|
||||
aria-hidden="true"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goBack } from "@/utils/navigation.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
action: { type: String, default: "" },
|
||||
root: { type: Boolean, default: false },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
fallbackUrl: { type: String, default: "/pages/genealogy/g01-my-genealogies" },
|
||||
notice: { type: Boolean, default: false },
|
||||
customBack: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["brand", "notice", "action", "back"]);
|
||||
const emit = defineEmits(["notice", "action", "back"]);
|
||||
|
||||
const goBack = () => {
|
||||
const handleBack = () => {
|
||||
if (props.customBack) {
|
||||
emit("back");
|
||||
return;
|
||||
}
|
||||
const stack = getCurrentPages();
|
||||
if (stack.length > 1) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: props.fallbackUrl });
|
||||
return goBack();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -180,6 +191,17 @@ const goBack = () => {
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
}
|
||||
.header-brand {
|
||||
display: grid;
|
||||
width: 88rpx;
|
||||
min-height: 88rpx;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
}
|
||||
.header-icon-button::after,
|
||||
.header-back::after,
|
||||
.header-action::after {
|
||||
@@ -214,13 +236,27 @@ const goBack = () => {
|
||||
}
|
||||
|
||||
.header-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ffe3a7;
|
||||
font-size: 27rpx;
|
||||
line-height: normal;
|
||||
text-align: right;
|
||||
}
|
||||
.header-action-placeholder {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
.header-action--root {
|
||||
color: #ffe3a7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-back {
|
||||
@@ -244,11 +280,6 @@ const goBack = () => {
|
||||
.header-back--pressed {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.header-action {
|
||||
color: #ffe3a7;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<view
|
||||
v-show="visible"
|
||||
class="tac-layer"
|
||||
:class="{ 'tac-layer--visible': visible }"
|
||||
:aria-hidden="visible ? 'false' : 'true'"
|
||||
@click.stop="requestCancel"
|
||||
>
|
||||
<view
|
||||
id="jiapu-tac-dialog"
|
||||
class="tac-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
aria-labelledby="jiapu-tac-title"
|
||||
aria-describedby="jiapu-tac-description"
|
||||
@click.stop
|
||||
@keydown.esc.stop.prevent="requestCancel"
|
||||
>
|
||||
<view class="tac-heading">
|
||||
<view class="tac-heading__copy">
|
||||
<text id="jiapu-tac-title" class="tac-title">安全验证</text>
|
||||
<text id="jiapu-tac-description" class="tac-description"
|
||||
>拖动滑块完成验证;可刷新当前挑战或关闭返回。</text
|
||||
>
|
||||
</view>
|
||||
<view class="tac-tools" role="group" aria-label="安全验证操作">
|
||||
<button
|
||||
class="tac-tool tac-tool--refresh"
|
||||
aria-label="刷新安全验证"
|
||||
@click.stop="requestRefresh"
|
||||
>↻</button>
|
||||
<button
|
||||
class="tac-tool tac-tool--close"
|
||||
aria-label="关闭安全验证"
|
||||
@click.stop="requestCancel"
|
||||
>×</button>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
id="jiapu-tac-host"
|
||||
class="tac-host"
|
||||
:prop="renderContext"
|
||||
:change:prop="tacRenderer.onContextChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TacVerification",
|
||||
data() {
|
||||
return {
|
||||
refreshSequence: 0,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
context: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["success", "failure", "error", "cancel"],
|
||||
computed: {
|
||||
renderContext() {
|
||||
return {
|
||||
...(this.context || {}),
|
||||
visible: this.visible,
|
||||
refreshSequence: this.refreshSequence,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
requestCancel() {
|
||||
if (this.visible) this.$emit("cancel");
|
||||
},
|
||||
requestRefresh() {
|
||||
if (this.visible) this.refreshSequence += 1;
|
||||
},
|
||||
handleTacSuccess(payload) {
|
||||
this.$emit("success", payload);
|
||||
},
|
||||
handleTacFailure(payload) {
|
||||
this.$emit("failure", payload);
|
||||
},
|
||||
handleTacError(payload) {
|
||||
this.$emit("error", payload);
|
||||
},
|
||||
handleTacCancel(payload) {
|
||||
this.$emit("cancel", payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script module="tacRenderer" lang="renderjs">
|
||||
const TAC_STYLE_URL = "./static/tac/css/tac.css";
|
||||
const TAC_SCRIPT_URL = "./static/tac/js/tac.min.js";
|
||||
const TAC_ADAPTER_URL = "./static/tac/js/jiapu-tac-adapter.js";
|
||||
|
||||
const loadStyle = (href) => {
|
||||
const existing = document.querySelector(`link[data-jiapu-tac="${href}"]`);
|
||||
if (existing && (existing.dataset.jiapuState === "loaded" || existing.sheet)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (existing && existing.dataset.jiapuState === "loading") {
|
||||
return new Promise((resolve, reject) => {
|
||||
existing.addEventListener("load", resolve, { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error(`安全验证样式加载失败:${href}`)), { once: true });
|
||||
});
|
||||
}
|
||||
if (existing) existing.remove();
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
link.setAttribute("data-jiapu-tac", href);
|
||||
link.dataset.jiapuState = "loading";
|
||||
link.onload = () => {
|
||||
link.dataset.jiapuState = "loaded";
|
||||
resolve();
|
||||
};
|
||||
link.onerror = () => {
|
||||
link.remove();
|
||||
reject(new Error(`安全验证样式加载失败:${href}`));
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
};
|
||||
|
||||
const loadScript = (src, ready) => {
|
||||
if (ready()) return Promise.resolve();
|
||||
const existing = document.querySelector(`script[data-jiapu-tac="${src}"]`);
|
||||
if (existing && existing.dataset.jiapuState === "loading") {
|
||||
return new Promise((resolve, reject) => {
|
||||
existing.addEventListener("load", () => {
|
||||
if (ready()) {
|
||||
existing.dataset.jiapuState = "loaded";
|
||||
resolve();
|
||||
} else {
|
||||
existing.remove();
|
||||
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
|
||||
}
|
||||
}, { once: true });
|
||||
existing.addEventListener("error", () => {
|
||||
existing.remove();
|
||||
reject(new Error(`安全验证脚本加载失败:${src}`));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
if (existing) existing.remove();
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.setAttribute("data-jiapu-tac", src);
|
||||
script.dataset.jiapuState = "loading";
|
||||
script.onload = () => {
|
||||
if (!ready()) {
|
||||
script.remove();
|
||||
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
|
||||
return;
|
||||
}
|
||||
script.dataset.jiapuState = "loaded";
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.remove();
|
||||
reject(new Error(`安全验证脚本加载失败:${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
generation: 0,
|
||||
tac: null,
|
||||
context: null,
|
||||
challenge: null,
|
||||
activeXhr: null,
|
||||
fatalSent: false,
|
||||
completionSent: false,
|
||||
previousFocus: null,
|
||||
focusTrapTarget: null,
|
||||
focusTrapHandler: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onContextChange(nextContext) {
|
||||
const wasVisible = Boolean(this.context && this.context.visible === true);
|
||||
const nextVisible = Boolean(nextContext && nextContext.visible === true);
|
||||
this.generation += 1;
|
||||
const generation = this.generation;
|
||||
if (nextVisible && !wasVisible) this.capturePreviousFocus();
|
||||
this.destroyTac(!nextVisible);
|
||||
this.context = nextContext || null;
|
||||
this.challenge = null;
|
||||
this.fatalSent = false;
|
||||
this.completionSent = false;
|
||||
if (!nextContext || nextContext.visible !== true) return;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadStyle(TAC_STYLE_URL),
|
||||
loadScript(TAC_SCRIPT_URL, () => Boolean(window.TAC && window.CaptchaConfig)),
|
||||
]);
|
||||
await loadScript(TAC_ADAPTER_URL, () => Boolean(window.JiapuTacAdapter));
|
||||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||||
this.createTac();
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||||
this.notifyFatal(error);
|
||||
}
|
||||
},
|
||||
createTac() {
|
||||
if (!window.TAC || !window.CaptchaConfig || !window.JiapuTacAdapter) {
|
||||
throw new Error("安全验证组件未完整加载");
|
||||
}
|
||||
this.completionSent = false;
|
||||
const generation = this.generation;
|
||||
const requestContext = this.context;
|
||||
const isCurrent = () =>
|
||||
generation === this.generation &&
|
||||
this.context === requestContext &&
|
||||
requestContext.visible === true;
|
||||
const config = new window.CaptchaConfig({
|
||||
bindEl: "#jiapu-tac-host",
|
||||
requestCaptchaDataUrl: this.context.challengeUrl,
|
||||
validCaptchaUrl: this.context.verifyUrl,
|
||||
requestHeaders: {
|
||||
clientid: this.context.clientId,
|
||||
},
|
||||
validSuccess: (response, captcha, tac) => {
|
||||
if (!isCurrent() || this.completionSent) return;
|
||||
// 供应商脚本在动画结束、窗口销毁等边界下可能重复触发回调。
|
||||
// 先封闭本轮并使所有旧闭包失效,再通知逻辑层,避免重复发短信或重复提交认证。
|
||||
this.completionSent = true;
|
||||
this.generation += 1;
|
||||
this.abortActiveRequest();
|
||||
const data = response && response.data;
|
||||
tac.destroyWindow();
|
||||
this.tac = null;
|
||||
this.$ownerInstance.callMethod("handleTacSuccess", {
|
||||
requestId: requestContext.requestId,
|
||||
validToken: data && data.validToken,
|
||||
expireSeconds: data && data.expireSeconds,
|
||||
});
|
||||
},
|
||||
validFail: (response, captcha, tac) => {
|
||||
if (!isCurrent()) return;
|
||||
this.$ownerInstance.callMethod("handleTacFailure", {
|
||||
requestId: requestContext.requestId,
|
||||
message: (response && response.msg) || "行为验证未通过,请重试",
|
||||
});
|
||||
tac.reloadCaptcha();
|
||||
},
|
||||
btnCloseFun: (event, tac) => {
|
||||
if (!isCurrent() || this.completionSent) return;
|
||||
this.completionSent = true;
|
||||
this.generation += 1;
|
||||
this.abortActiveRequest();
|
||||
tac.destroyWindow();
|
||||
this.tac = null;
|
||||
this.$ownerInstance.callMethod("handleTacCancel", {
|
||||
requestId: requestContext.requestId,
|
||||
});
|
||||
},
|
||||
btnRefreshFun: (event, tac) => {
|
||||
if (isCurrent()) tac.reloadCaptcha();
|
||||
},
|
||||
});
|
||||
|
||||
// SDK 原生传输会把空体 HTTP 500 当成功,并且 verify 只看外层 code。
|
||||
// 此处统一替换为严格 2xx JSON 传输,再由唯一适配器验证 passed 与 validToken。
|
||||
config.doSendRequest = (options) => this.sendStrictRequest(options);
|
||||
config.addRequestChain({
|
||||
preRequest: (type, request) => this.beforeRequest(type, request),
|
||||
postRequest: (type, request, response) => this.afterRequest(type, response),
|
||||
});
|
||||
this.tac = new window.TAC(config, {
|
||||
logoUrl: null,
|
||||
i18n: {
|
||||
tips_success: "验证成功",
|
||||
tips_error: "验证失败,请重新尝试",
|
||||
slider_title: "拖动滑块完成安全验证",
|
||||
rotate_title: "拖动滑块完成安全验证",
|
||||
concat_title: "拖动滑块完成拼图",
|
||||
image_click_title: "请依次点击",
|
||||
},
|
||||
});
|
||||
this.tac.init();
|
||||
this.activateFocusTrap();
|
||||
},
|
||||
beforeRequest(type, request) {
|
||||
if (type === "requestCaptchaData") {
|
||||
request.data = {
|
||||
tenantId: this.context.tenantId,
|
||||
clientId: this.context.clientId,
|
||||
sceneCode: this.context.sceneCode,
|
||||
subject: this.context.subject,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (type === "validCaptcha") {
|
||||
request.data = window.JiapuTacAdapter.buildVerifyBody(
|
||||
request.data,
|
||||
this.context,
|
||||
this.challenge,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
afterRequest(type, response) {
|
||||
try {
|
||||
if (type === "requestCaptchaData") {
|
||||
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.context);
|
||||
this.challenge = mapped.challenge;
|
||||
this.replaceResponse(response, mapped.sdkResponse);
|
||||
} else if (type === "validCaptcha") {
|
||||
const mapped = window.JiapuTacAdapter.normalizeVerifyResponse(response);
|
||||
this.replaceResponse(response, mapped);
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = window.JiapuTacAdapter.toSdkFailure(error);
|
||||
this.replaceResponse(response, failure);
|
||||
if (type === "requestCaptchaData") this.notifyFatal(error);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
replaceResponse(target, source) {
|
||||
Object.keys(target).forEach((key) => delete target[key]);
|
||||
Object.assign(target, source);
|
||||
},
|
||||
sendStrictRequest(options) {
|
||||
return new Promise((resolve) => {
|
||||
this.abortActiveRequest();
|
||||
const generation = this.generation;
|
||||
const xhr = new XMLHttpRequest();
|
||||
this.activeXhr = xhr;
|
||||
let settled = false;
|
||||
const settle = (response) => {
|
||||
if (settled || generation !== this.generation || this.activeXhr !== xhr) return;
|
||||
settled = true;
|
||||
this.activeXhr = null;
|
||||
resolve(response);
|
||||
};
|
||||
xhr.open(options.method || "POST", options.url);
|
||||
xhr.timeout = 15000;
|
||||
Object.keys(options.headers || {}).forEach((name) => {
|
||||
xhr.setRequestHeader(name, String(options.headers[name]));
|
||||
});
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
||||
if (!(xhr.status >= 200 && xhr.status < 300)) {
|
||||
settle({ code: xhr.status || 503, msg: `安全验证服务请求失败(${xhr.status || "网络异常"})` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
settle(data && typeof data === "object" ? data : { code: 502, msg: "安全验证服务返回无效数据" });
|
||||
} catch (error) {
|
||||
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => settle({ code: 503, msg: "安全验证服务网络连接失败" });
|
||||
xhr.ontimeout = () => settle({ code: 504, msg: "安全验证服务请求超时" });
|
||||
const payload = typeof options.data === "string" ? options.data : JSON.stringify(options.data || {});
|
||||
xhr.send(payload);
|
||||
});
|
||||
},
|
||||
abortActiveRequest() {
|
||||
const xhr = this.activeXhr;
|
||||
this.activeXhr = null;
|
||||
if (!xhr || xhr.readyState === XMLHttpRequest.DONE) return;
|
||||
xhr.onreadystatechange = null;
|
||||
xhr.onerror = null;
|
||||
xhr.ontimeout = null;
|
||||
xhr.onabort = null;
|
||||
xhr.abort();
|
||||
},
|
||||
notifyFatal(error) {
|
||||
if (this.fatalSent) return;
|
||||
this.fatalSent = true;
|
||||
this.$ownerInstance.callMethod("handleTacError", {
|
||||
requestId: this.context && this.context.requestId,
|
||||
message: (error && error.message) || "安全验证暂不可用",
|
||||
});
|
||||
},
|
||||
capturePreviousFocus() {
|
||||
const previousFocus = document.activeElement;
|
||||
this.previousFocus = previousFocus && typeof previousFocus.focus === "function"
|
||||
? previousFocus
|
||||
: null;
|
||||
},
|
||||
activateFocusTrap() {
|
||||
this.deactivateFocusTrap(false);
|
||||
const panel = document.querySelector("#jiapu-tac-dialog");
|
||||
if (!panel) return;
|
||||
const getFocusable = () => Array.from(panel.querySelectorAll(
|
||||
'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => element.offsetParent !== null);
|
||||
this.focusTrapHandler = (event) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = getFocusable();
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
this.focusTrapTarget = panel;
|
||||
panel.addEventListener("keydown", this.focusTrapHandler);
|
||||
const initialFocus = panel.querySelector(".tac-tool--refresh") || panel;
|
||||
setTimeout(() => initialFocus.focus?.(), 0);
|
||||
},
|
||||
deactivateFocusTrap(restoreFocus = false) {
|
||||
if (this.focusTrapTarget && this.focusTrapHandler) {
|
||||
this.focusTrapTarget.removeEventListener("keydown", this.focusTrapHandler);
|
||||
}
|
||||
this.focusTrapTarget = null;
|
||||
this.focusTrapHandler = null;
|
||||
if (!restoreFocus) return;
|
||||
const previousFocus = this.previousFocus;
|
||||
this.previousFocus = null;
|
||||
setTimeout(() => previousFocus?.focus?.(), 0);
|
||||
},
|
||||
destroyTac(restoreFocus = true) {
|
||||
this.abortActiveRequest();
|
||||
if (this.tac) this.tac.destroyWindow();
|
||||
this.tac = null;
|
||||
const host = document.querySelector("#jiapu-tac-host");
|
||||
if (host) host.innerHTML = "";
|
||||
this.deactivateFocusTrap(restoreFocus);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tac-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 32rpx;
|
||||
background: rgba(34, 19, 12, 0.62);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.tac-layer--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tac-panel {
|
||||
box-sizing: border-box;
|
||||
width: 318px;
|
||||
max-width: 100%;
|
||||
max-height: calc(var(--app-viewport-height) - 2px);
|
||||
min-height: 318px;
|
||||
border: 2rpx solid rgba(117, 25, 19, 0.48);
|
||||
border-radius: 12rpx;
|
||||
background: #f7f0e5;
|
||||
box-shadow: 0 16rpx 52rpx rgba(39, 18, 10, 0.28);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tac-heading {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 4px 6px 12px;
|
||||
border-bottom: 1px solid rgba(117, 25, 19, 0.18);
|
||||
}
|
||||
|
||||
.tac-heading__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tac-title {
|
||||
color: #8f160f;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tac-description {
|
||||
margin-top: 2px;
|
||||
color: #5c4330;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tac-tools {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tac-tool {
|
||||
display: inline-flex;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8f160f;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tac-tool::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
:deep(.slider-bottom .close-btn),
|
||||
:deep(.slider-bottom .refresh-btn) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tac-host {
|
||||
width: 318px;
|
||||
max-width: 100%;
|
||||
min-height: 318px;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.tac-layer {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.tac-panel {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,334 +0,0 @@
|
||||
<!-- T04-T06 共用成员表单:仅复用视觉与交互骨架,各页面通过 kind 拥有独立任务和文案。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-form-page"
|
||||
:class="{
|
||||
'form-state--form': formState === 'form',
|
||||
'form-state--success': formState === 'success',
|
||||
'form-state--conflict': formState === 'conflict',
|
||||
'form-state--error': formState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="member-form-page__header"
|
||||
><PageHeader :title="config.pageTitle"
|
||||
/></view>
|
||||
<view class="member-form-panel">
|
||||
<view v-if="formState === 'form'" class="member-form">
|
||||
<text class="member-form__eyebrow">{{ config.eyebrow }}</text>
|
||||
<text class="member-form__title">{{ config.title }}</text>
|
||||
<text class="member-form__copy">{{ config.copy }}</text>
|
||||
<view
|
||||
v-for="field in config.fields"
|
||||
:key="field.key"
|
||||
class="member-field"
|
||||
>
|
||||
<text>{{ field.label }}</text>
|
||||
<input
|
||||
v-model="form[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
placeholder-class="member-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text class="member-form__note">{{ config.note }}</text>
|
||||
<view class="member-form-action" @click="saveForm">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ config.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="member-form-result">
|
||||
<text class="member-form__eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||
<text class="member-form__title">{{ resultCopy.title }}</text>
|
||||
<text class="member-form__copy">{{ resultCopy.copy }}</text>
|
||||
<view v-if="formState === 'conflict'" class="conflict-actions">
|
||||
<view class="member-form-action" @click="formState = 'form'">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text class="member-form-action__secondary">返回核对</text>
|
||||
</view>
|
||||
<view class="member-form-action" @click="showConflictHelp">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>查看冲突</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="member-form-action" @click="formState = 'form'">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ formState === "success" ? "继续完善" : "重新填写" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="conflictHelpVisible"
|
||||
eyebrow="关系校验规则"
|
||||
title="关系冲突说明"
|
||||
message="同一成员不能同时存在两个生父,也不能形成上下代循环。请返回世系树核对原关系后再调整。"
|
||||
@confirm="conflictHelpVisible = false"
|
||||
@close="conflictHelpVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const props = defineProps({ kind: { type: String, required: true } });
|
||||
const configs = {
|
||||
add: {
|
||||
pageTitle: "新增亲属",
|
||||
eyebrow: "补全家族关系",
|
||||
title: "为汤文远添加一位亲属",
|
||||
copy: "先确认与当前成员的关系,再录入基础身份信息。",
|
||||
action: "保存亲属",
|
||||
note: "保存后将在世系图中显示,可继续补充详细档案。",
|
||||
fields: [
|
||||
{ key: "name", label: "姓名", placeholder: "请输入真实姓名" },
|
||||
{ key: "relation", label: "与本人关系", placeholder: "例如:长子、配偶" },
|
||||
{ key: "birthDate", label: "出生日期", placeholder: "例如:1992年" },
|
||||
{ key: "gender", label: "性别", placeholder: "请选择或输入" },
|
||||
],
|
||||
},
|
||||
edit: {
|
||||
pageTitle: "编辑成员",
|
||||
eyebrow: "成员档案维护",
|
||||
title: "完善汤文远的生命记录",
|
||||
copy: "基础身份用于世系展示,生平信息可在成员档案中继续补充。",
|
||||
action: "保存资料",
|
||||
note: "隐私字段只向本人和有权限的家谱管理员展示。",
|
||||
fields: [
|
||||
{ key: "name", label: "姓名", placeholder: "请输入首位成员姓名" },
|
||||
{ key: "generation", label: "字辈", placeholder: "例如:文" },
|
||||
{ key: "birthDate", label: "出生日期", placeholder: "1940年" },
|
||||
{ key: "summary", label: "人物简介", placeholder: "简要记录生平" },
|
||||
],
|
||||
},
|
||||
relation: {
|
||||
pageTitle: "关系维护",
|
||||
eyebrow: "亲属关系校正",
|
||||
title: "确认两位成员的家族关系",
|
||||
copy: "调整关系会影响世系位置,保存前请核对成员和关系类型。",
|
||||
action: "保存关系",
|
||||
note: "若形成重复父母、代际倒置等情况,页面会先提示冲突。",
|
||||
fields: [
|
||||
{ key: "source", label: "当前成员", placeholder: "请选择当前成员" },
|
||||
{ key: "target", label: "关联成员", placeholder: "请选择家谱成员" },
|
||||
{ key: "relation", label: "关系类型", placeholder: "父子、配偶等" },
|
||||
{ key: "effectiveDate", label: "生效日期", placeholder: "选填" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const config = computed(() => configs[props.kind]);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
relation: "",
|
||||
birthDate: "",
|
||||
gender: "",
|
||||
generation: "",
|
||||
summary: "",
|
||||
source: "",
|
||||
target: "",
|
||||
effectiveDate: "",
|
||||
});
|
||||
const query = (() => {
|
||||
if (typeof location !== "undefined")
|
||||
return Object.fromEntries(
|
||||
new URLSearchParams(location.hash.split("?")[1] || ""),
|
||||
);
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.options || {};
|
||||
})();
|
||||
const formState = ref(
|
||||
query.state === "success"
|
||||
? "success"
|
||||
: query.state === "conflict"
|
||||
? "conflict"
|
||||
: query.state === "error"
|
||||
? "error"
|
||||
: "form",
|
||||
);
|
||||
const conflictHelpVisible = ref(false);
|
||||
const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
success: {
|
||||
eyebrow: "本地预览已更新",
|
||||
title: `${config.value.pageTitle}内容已保存`,
|
||||
copy: "返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。",
|
||||
},
|
||||
conflict: {
|
||||
eyebrow: "发现关系冲突",
|
||||
title: "这段关系会造成代际矛盾",
|
||||
copy: "目标成员已经存在父级关系,请先核对原关系,再决定是否调整。",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "内容未保存",
|
||||
title: "暂时无法完成这次操作",
|
||||
copy: "请返回成员档案重新进入,当前填写内容不会写入正式数据。",
|
||||
},
|
||||
})[formState.value] || {},
|
||||
);
|
||||
|
||||
const saveForm = () => {
|
||||
if (
|
||||
props.kind === "relation" &&
|
||||
(!form.target.trim() || !form.relation.trim())
|
||||
) {
|
||||
formState.value = "conflict";
|
||||
return;
|
||||
}
|
||||
formState.value = "success";
|
||||
};
|
||||
const showConflictHelp = () => {
|
||||
conflictHelpVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.member-form-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.member-form-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.member-form-panel {
|
||||
@include adaptive.adaptive-tree-panel;
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
min-height: min(640px, calc((100vw - 16px) * 1.48));
|
||||
margin: 18rpx auto 0;
|
||||
padding: 7.5% 8%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-form,
|
||||
.member-form-result {
|
||||
z-index: 1;
|
||||
}
|
||||
.member-form__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.member-form__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-form__copy {
|
||||
display: block;
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-field {
|
||||
@include adaptive.adaptive-tree-field;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-field > text {
|
||||
z-index: 1;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-field input {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 54rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.member-placeholder {
|
||||
color: #a79884;
|
||||
}
|
||||
.member-form__note {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin-top: 17rpx;
|
||||
}
|
||||
.member-form-action image {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-form-action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-form-action .member-form-action__secondary {
|
||||
color: $ink;
|
||||
}
|
||||
.member-form-result {
|
||||
margin-top: 21.5%;
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-result .member-form__eyebrow {
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-result .member-form__copy {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.member-form-result > .member-form-action {
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
margin: 32rpx auto 0;
|
||||
}
|
||||
.conflict-actions {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
.conflict-actions .member-form-action {
|
||||
width: calc(50% - 7rpx);
|
||||
margin-top: 0;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.member-form-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user