ow
This commit is contained in:
1 parent
3b2ee8523b
commit
b5d2a2ede0
17 files changed
+2061
-1484
No files matched your search
@@ -275,6 +275,81 @@ function toggleDirection(): void {
|
||||
vertical.value = !vertical.value;
|
||||
}
|
||||
|
||||
// ===== 控制条可拖拽:按住顶部握把行挪位(拖拽热区只在握把,不碰动作钮),
|
||||
// ctrlOffset 为整条相对默认右上锚点的位移 =====
|
||||
const ctrlOffset = ref({ x: 0, y: 0 });
|
||||
let dragStart: { tx: number; ty: number; ox: number; oy: number } | null = null;
|
||||
|
||||
function onCtrlTouchStart(e: TouchEvent): void {
|
||||
const t0 = e.touches?.[0];
|
||||
if (!t0) {
|
||||
return;
|
||||
}
|
||||
dragStart = { tx: t0.clientX, ty: t0.clientY, ox: ctrlOffset.value.x, oy: ctrlOffset.value.y };
|
||||
}
|
||||
|
||||
function onCtrlTouchMove(e: TouchEvent): void {
|
||||
const t0 = e.touches?.[0];
|
||||
if (!dragStart || !t0) {
|
||||
return;
|
||||
}
|
||||
const sys = uni.getSystemInfoSync();
|
||||
const barW = uni.upx2px(88);
|
||||
const barH = uni.upx2px(384); // 握把 + 4 动作钮各 76 + 分隔线
|
||||
const edge = uni.upx2px(16);
|
||||
// 默认锚点(top/right 定位)换算成 left/top 基准,再按屏幕边界夹取
|
||||
const baseLeft = sys.windowWidth - uni.upx2px(24) - barW;
|
||||
const baseTop = uni.upx2px(120) + (sys.safeAreaInsets?.top || 0);
|
||||
const minX = edge - baseLeft;
|
||||
const maxX = sys.windowWidth - edge - barW - baseLeft;
|
||||
const minY = edge + uni.upx2px(110) + (sys.safeAreaInsets?.top || 0) - baseTop;
|
||||
const maxY = sys.windowHeight - edge - barH - baseTop;
|
||||
ctrlOffset.value = {
|
||||
x: Math.min(Math.max(dragStart.ox + t0.clientX - dragStart.tx, minX), maxX),
|
||||
y: Math.min(Math.max(dragStart.oy + t0.clientY - dragStart.ty, minY), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function onCtrlTouchEnd(): void {
|
||||
dragStart = null;
|
||||
}
|
||||
|
||||
// ===== 双指捏合缩放:画布触摸层两指距离比换算 zoom(5% 步进,比 ± 钮细腻)=====
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartZoom = 1;
|
||||
|
||||
function touchGap(t0: Touch, t1: Touch): number {
|
||||
return Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||
}
|
||||
|
||||
function onChartTouchStart(e: TouchEvent): void {
|
||||
if (e.touches?.length === 2) {
|
||||
pinchStartDist = touchGap(e.touches[0], e.touches[1]);
|
||||
pinchStartZoom = zoom.value;
|
||||
} else {
|
||||
pinchStartDist = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onChartTouchMove(e: TouchEvent): void {
|
||||
if (e.touches?.length !== 2 || pinchStartDist <= 0) {
|
||||
return;
|
||||
}
|
||||
const ratio = touchGap(e.touches[0], e.touches[1]) / pinchStartDist;
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(pinchStartZoom * ratio * 20) / 20));
|
||||
if (next !== zoom.value) {
|
||||
// H5:捏合期间拦掉默认滚动,避免双指被 scroll-view 当成拖动
|
||||
e.preventDefault?.();
|
||||
zoom.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
function onChartTouchEnd(e: TouchEvent): void {
|
||||
if (!e.touches || e.touches.length < 2) {
|
||||
pinchStartDist = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function requestLoad(personId: string): void {
|
||||
emit("select-child", personId);
|
||||
}
|
||||
@@ -457,15 +532,18 @@ function close(): void {
|
||||
<text class="ftc__title">
|
||||
{{ props.title || t('FamilyTreePage.pedigreeChart') }}
|
||||
</text>
|
||||
<!-- 退出钮统一为「取消」文字胶囊(底部抽屉风格语言,不再用 X) -->
|
||||
<view class="ftc__cancel" @tap="close">
|
||||
<text class="ftc__cancel-text">
|
||||
{{ t("FamilyTreePage.cancel") }}
|
||||
</text>
|
||||
<!-- 退出钮按用户要求改为 × 关闭图标(灰底圆钮) -->
|
||||
<view class="ftc__close" @tap="close">
|
||||
<u-icon name="close" size="28" color="#5c626b" />
|
||||
</view>
|
||||
</view>
|
||||
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮) -->
|
||||
<view class="ftc__ctrl">
|
||||
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮);
|
||||
会挡树图内容,按住顶部握把行可拖拽挪位;动作钮只响应单击,与拖拽互不干扰 -->
|
||||
<view class="ftc__ctrl" :style="{ transform: `translate(${ctrlOffset.x}px, ${ctrlOffset.y}px)` }">
|
||||
<view class="ftc__ctrl-grip" @touchstart="onCtrlTouchStart" @touchmove="onCtrlTouchMove"
|
||||
@touchend="onCtrlTouchEnd" @touchcancel="onCtrlTouchEnd">
|
||||
<text class="ftc__ctrl-grip-dot">⠿</text>
|
||||
</view>
|
||||
<view class="ftc__ctrl-btn" @tap="zoomIn">
|
||||
<text class="ftc__ctrl-glyph">+</text>
|
||||
</view>
|
||||
@@ -479,7 +557,9 @@ function close(): void {
|
||||
<text class="ftc__ctrl-glyph">⇅</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="ftc__scroll" scroll-x scroll-y :show-scrollbar="true">
|
||||
<!-- 画布:双指捏合缩放(touch 层只识别两指,单指滚动不受影响) -->
|
||||
<scroll-view class="ftc__scroll" scroll-x scroll-y :show-scrollbar="true" @touchstart="onChartTouchStart"
|
||||
@touchmove="onChartTouchMove" @touchend="onChartTouchEnd" @touchcancel="onChartTouchEnd">
|
||||
<view v-if="hasRoot" class="ftc__tree" :style="{ zoom }">
|
||||
<family-tree-node :person-id="rootPersonId" :preview="null" :expandable="true" :depth="0" />
|
||||
</view>
|
||||
@@ -518,18 +598,17 @@ function close(): void {
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ftc__cancel {
|
||||
padding: 12rpx 40rpx;
|
||||
border-radius: 999rpx;
|
||||
.ftc__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ftc__cancel-text {
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
/* 悬浮控制条:右侧竖排,白底圆角轻阴影 */
|
||||
/* 悬浮控制条:右侧竖排,白底圆角轻阴影;整条可拖拽(见 ctrlOffset) */
|
||||
.ftc__ctrl {
|
||||
position: absolute;
|
||||
top: calc(120rpx + env(safe-area-inset-top));
|
||||
@@ -543,6 +622,24 @@ function close(): void {
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 顶部握把行:拖拽热区(touch-action:none 只加在这里,动作钮手势不受影响);
|
||||
与动作钮等高,手指易按住 */
|
||||
.ftc__ctrl-grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76rpx;
|
||||
border-bottom: 2rpx solid #f2f3f5;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.ftc__ctrl-grip-dot {
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.ftc__ctrl-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,13 +43,17 @@ function sizePx(): number {
|
||||
return uni.upx2px(112);
|
||||
}
|
||||
|
||||
/** 夹在屏幕可视区内,四边留 8px 呼吸位 */
|
||||
/** 夹在屏幕可视区内:左右/底缘留 8px 呼吸位;顶让出导航栏条带、底让出 tabbar 条带——
|
||||
* 两带是 z-index 更高的 fixed/sticky 层(page-navbar 为 9,本球仅 8),球一旦进入,
|
||||
* mousedown/touchstart 会被上层截走,表现为"球卡死拖不动"(位置全页面共享存储,一处卡处处卡) */
|
||||
function clampPos(x: number, y: number): { x: number; y: number } {
|
||||
const { windowWidth, windowHeight } = uni.getSystemInfoSync();
|
||||
const { windowWidth, windowHeight, statusBarHeight = 0, safeAreaInsets } = uni.getSystemInfoSync();
|
||||
const size = sizePx();
|
||||
const minY = (statusBarHeight || 0) + uni.upx2px(100);
|
||||
const maxY = windowHeight - size - (safeAreaInsets?.bottom ?? 0) - uni.upx2px(110);
|
||||
return {
|
||||
x: Math.min(Math.max(8, x), windowWidth - size - 8),
|
||||
y: Math.min(Math.max(8, y), windowHeight - size - 8),
|
||||
y: Math.min(Math.max(minY, y), Math.max(minY, maxY)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_
|
||||
<view v-if="props.showQuickToggle" class="page-navbar__btn" @tap="emit('toggle-quick')">
|
||||
<u-icon name="grid" size="38" :color="props.quickOn ? '#2b8fff' : '#8f959e'" />
|
||||
</view>
|
||||
<!-- 页面自定义动作区(如首页「新建族谱」圆钮):追加在最右,不填不影响既有页面 -->
|
||||
<slot name="actions" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -183,6 +183,13 @@ function attachHls(): void {
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(el);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// m3u8 清单解析完即有总时长:先取 level 的 totalDuration,再兜底读元素
|
||||
const d = Number(hls?.levels?.[0]?.totalDuration) || 0;
|
||||
if (d > 0 && isFinite(d)) {
|
||||
duration.value = d;
|
||||
} else {
|
||||
syncDuration();
|
||||
}
|
||||
if (pending != null) {
|
||||
el.currentTime = pending;
|
||||
}
|
||||
@@ -382,8 +389,24 @@ function onWaiting(): void {
|
||||
function onPlaying(): void {
|
||||
buffering.value = false;
|
||||
}
|
||||
function onCanplay(): void {
|
||||
function onCanplay(e: { detail?: { duration?: number } }): void {
|
||||
buffering.value = false;
|
||||
// 未点播放也先亮总时长:uni canplay 的 detail.duration(H5/App/小程序都有)
|
||||
const d = Number(e?.detail?.duration) || 0;
|
||||
if (d > 0 && isFinite(d)) {
|
||||
duration.value = d;
|
||||
return;
|
||||
}
|
||||
syncDuration();
|
||||
}
|
||||
/** H5 兜底:从内层 <video> 元素读 duration(loadedmetadata 后即可用) */
|
||||
function syncDuration(): void {
|
||||
// #ifdef H5
|
||||
const d = Number(getInnerVideo()?.duration) || 0;
|
||||
if (d > 0 && isFinite(d)) {
|
||||
duration.value = d;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
function onTimeUpdate(e: { detail: { currentTime: number; duration: number } }): void {
|
||||
// 画面在推进就说明已经播出来了,清掉缓冲转圈(uni video 的 playing/canplay 在 H5 不一定派发)
|
||||
@@ -392,12 +415,33 @@ function onTimeUpdate(e: { detail: { currentTime: number; duration: number } }):
|
||||
if (e.detail?.duration && isFinite(e.detail.duration)) {
|
||||
duration.value = e.detail.duration;
|
||||
}
|
||||
syncBuffer();
|
||||
}
|
||||
function onProgress(e: { detail: { buffered: number } }): void {
|
||||
const b = e.detail?.buffered;
|
||||
if (typeof b === "number") {
|
||||
bufferedRatio.value = Math.min(1, Math.max(bufferedRatio.value, b / 100));
|
||||
}
|
||||
syncBuffer();
|
||||
}
|
||||
/**
|
||||
* H5 校准缓冲条:原生 buffered 常留最后零点几秒不放(要播到才认),
|
||||
* 导致进度条尾部永远露出一截轨道底色、与缓冲条不重合;距结束 1s 内视为满缓冲。
|
||||
*/
|
||||
function syncBuffer(): void {
|
||||
// #ifdef H5
|
||||
const el = getInnerVideo();
|
||||
if (!el?.buffered?.length || !duration.value) {
|
||||
return;
|
||||
}
|
||||
let end = 0;
|
||||
for (let i = 0; i < el.buffered.length; i++) {
|
||||
end = Math.max(end, Number(el.buffered.end(i)) || 0);
|
||||
}
|
||||
if (end >= duration.value - 1) {
|
||||
bufferedRatio.value = 1;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
function onLoadedmetadata(e: { detail: { width: number; height: number } }): void {
|
||||
const w = e.detail?.width ?? 0;
|
||||
@@ -405,6 +449,8 @@ function onLoadedmetadata(e: { detail: { width: number; height: number } }): voi
|
||||
if (w && h) {
|
||||
landscape.value = w > h;
|
||||
}
|
||||
// 元数据就绪即可拿时长,不等播放(H5)
|
||||
syncDuration();
|
||||
if (pendingSeek != null) {
|
||||
ensureCtx();
|
||||
ctx?.seek(pendingSeek);
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"personSearch": "Member Search",
|
||||
"pedigreeChart": "Pedigree Chart",
|
||||
"ancestryChart": "Ancestry Chart",
|
||||
"cancel": "Cancel",
|
||||
"chartEmpty": "No family tree data",
|
||||
"applyManage": "Apply for Manager",
|
||||
"requestSentTip": "Request submitted, waiting for approval",
|
||||
@@ -119,7 +118,13 @@
|
||||
"memberTag": "Member",
|
||||
"inputTreeIdTip": "Please enter tree ID",
|
||||
"noAmendTip": "You have no permission to amend this family tree",
|
||||
"treeNameRequired": "Please enter tree name"
|
||||
"treeNameRequired": "Please enter tree name",
|
||||
"unnamed": "Unnamed Tree",
|
||||
"noBrief": "No brief",
|
||||
"chipPublic": "Public",
|
||||
"chipPrivate": "Private",
|
||||
"searchPlaceholder": "Search tree name",
|
||||
"listTitle": "Family Trees"
|
||||
},
|
||||
"FamilyPersonPage": {
|
||||
"title": "Family Member Query",
|
||||
@@ -622,6 +627,7 @@
|
||||
"noPermissionText": "No accessible admin function",
|
||||
"menuLoading": "Loading menu…",
|
||||
"menuRetryBut": "Reload",
|
||||
"menuSection": "Features",
|
||||
"menuEmptyText": "The server returned no authorized function",
|
||||
"loadingTip": "Loading…",
|
||||
"savingTip": "Saving…"
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"personSearch": "族员搜索",
|
||||
"pedigreeChart": "谱系图",
|
||||
"ancestryChart": "祖谱系图",
|
||||
"cancel": "取消",
|
||||
"chartEmpty": "暂无族谱数据",
|
||||
"applyManage": "申请管理",
|
||||
"requestSentTip": "申请已提交,等待管理员审批",
|
||||
@@ -119,7 +118,13 @@
|
||||
"memberTag": "成员",
|
||||
"inputTreeIdTip": "请输入族谱编号",
|
||||
"noAmendTip": "您没有该族谱的修改权限",
|
||||
"treeNameRequired": "请输入族谱名称"
|
||||
"treeNameRequired": "请输入族谱名称",
|
||||
"unnamed": "未命名族谱",
|
||||
"noBrief": "暂无简介",
|
||||
"chipPublic": "公开",
|
||||
"chipPrivate": "私密",
|
||||
"searchPlaceholder": "搜索族谱名称",
|
||||
"listTitle": "有家族谱"
|
||||
},
|
||||
"FamilyPersonPage": {
|
||||
"title": "家族成员查询",
|
||||
@@ -622,6 +627,7 @@
|
||||
"noPermissionText": "没有可访问的管理功能",
|
||||
"menuLoading": "菜单加载中…",
|
||||
"menuRetryBut": "重新加载",
|
||||
"menuSection": "功能菜单",
|
||||
"menuEmptyText": "后端没有返回你有权限的功能",
|
||||
"loadingTip": "加载中…",
|
||||
"savingTip": "保存中…"
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"mode": "history",
|
||||
"base": "/"
|
||||
},
|
||||
"devServer": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5173
|
||||
},
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
|
||||
@@ -91,14 +91,8 @@
|
||||
{
|
||||
"path": "pages/security/help-feature-operation"
|
||||
},
|
||||
// 族谱管理(/family-tree-enquiry、/family-tree-operation):旧 master/FamilyTree 两页迁移,挂安全管理枢纽下
|
||||
{
|
||||
"path": "pages/security/family-tree-enquiry"
|
||||
},
|
||||
{
|
||||
"path": "pages/security/family-tree-operation"
|
||||
},
|
||||
// 家族成员(/family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器
|
||||
// 族谱管理已融合进首页(pages/dashboard/dashboard 两态页),旧 family-tree-* 两页已退役
|
||||
{
|
||||
"path": "pages/security/family-person-enquiry"
|
||||
},
|
||||
|
||||
@@ -1,127 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登录后的落地页:后端按用户下发的功能菜单驱动入口网格
|
||||
*
|
||||
* 数据链路(详见 composables/useAdminMenu.ts):
|
||||
* /SecurityUser/FindMenuByUsername(按用户授权)-> menu-map.ts 白名单 -> canVisitPage 复核。
|
||||
* 三层过滤后剩下的才是能点的入口,绝不用 FindMenus 全量表兜底(会列出未授权功能)。
|
||||
* 首页壳页面(H5 `/` 入口 + tab 下标 0):族谱管理两态页已拆为
|
||||
* components/family-tree-home 组件(easycom 自动注册),本页只做两件事:
|
||||
* 1. 页面级登录守卫(useAuthGuard 依赖 onShow,子组件里不触发,必须留在页面);
|
||||
* 2. 守卫通过后让组件在列表态重拉数据(详情编辑中不打断,口径由组件 reloadIfList 自定)。
|
||||
* 时序坑:首次进入时 onShow 早于子组件挂载,homeRef 还是 null——直接空调会被
|
||||
* 可选链静默吞掉(表现为空态无数据),故未挂载时排队,等组件 emit ready 再补发。
|
||||
* 后续首页要挂其他内容(功能菜单等),在本页并列追加即可,族谱逻辑不受影响。
|
||||
*/
|
||||
import type { PageName } from "@/router/routes";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed } from "vue";
|
||||
import { useAdminMenu, useAuthGuard } from "@/composables";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import { ref } from "vue";
|
||||
import { useAuthGuard } from "@/composables";
|
||||
|
||||
const { t } = useLocale();
|
||||
const menu = useAdminMenu();
|
||||
/** 结构类型即可:只用到组件 defineExpose 的 reloadIfList,避免引组件类型造成循环引用 */
|
||||
const homeRef = ref<{ reloadIfList: () => void } | null>(null);
|
||||
/** 守卫已过但组件尚未挂载:记挂一次首拉,ready 时补发 */
|
||||
let pendingReload = false;
|
||||
|
||||
/** 可见入口:useAdminMenu 里已做过白名单 + canVisitPage 两道过滤 */
|
||||
const items = computed(() => menu.visible.value);
|
||||
/** 拉取失败(不是"没权限"就是网络/后端异常):给出重试入口而不是空一片 */
|
||||
const failed = computed(() => !menu.loading.value && Boolean(menu.errorText.value));
|
||||
|
||||
function openPage(page: PageName): void {
|
||||
uni.navigateTo({ url: pageUrl(page) });
|
||||
}
|
||||
|
||||
// 菜单拉取排在登录守卫之后;refresh 自带按账号的模块级缓存,反复 onShow 不会重复打接口
|
||||
useAuthGuard(() => {
|
||||
menu.refresh();
|
||||
if (homeRef.value) {
|
||||
homeRef.value.reloadIfList();
|
||||
}
|
||||
else {
|
||||
pendingReload = true;
|
||||
}
|
||||
});
|
||||
|
||||
function onHomeReady(): void {
|
||||
if (pendingReload) {
|
||||
pendingReload = false;
|
||||
homeRef.value?.reloadIfList();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-page :nav-title="WebSitePolicy.Title" :show-nav-back="false" :hide-nav="true" :show-tabbar="true">
|
||||
<view class="dashboard">
|
||||
<view v-if="menu.loading.value" class="dashboard__tip">
|
||||
<text>{{ t("AdminPage.menuLoading") }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="failed" class="dashboard__tip">
|
||||
<text>{{ t("AdminPage.noPermissionText") }}</text>
|
||||
<u-button class="dashboard__retry" shape="circle" size="mini" :text="t('AdminPage.menuRetryBut')"
|
||||
@click="menu.refresh(true)" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else class="dashboard__scroll" scroll-y :show-scrollbar="false">
|
||||
<view v-if="!items.length" class="dashboard__empty">
|
||||
<u-empty mode="list" :text="t('AdminPage.menuEmptyText')" />
|
||||
</view>
|
||||
|
||||
<view v-else class="grid">
|
||||
<view v-for="item in items" :key="item.function" class="grid-item" @click="openPage(item.page)">
|
||||
<view class="grid-icon">
|
||||
<u-icon :name="item.icon" size="46" color="var(--u-type-primary)" />
|
||||
</view>
|
||||
<text class="grid-title">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</app-page>
|
||||
<family-tree-home ref="homeRef" @ready="onHomeReady" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 60vh;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.dashboard__tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: $u-tips-color;
|
||||
}
|
||||
|
||||
.dashboard__retry {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.dashboard__scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dashboard__empty {
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 33.33%;
|
||||
padding: 28rpx 0;
|
||||
}
|
||||
|
||||
.grid-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
margin-bottom: 12rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
font-size: 24rpx;
|
||||
color: $u-content-color;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
* 保留:四条筛选(谱名/常用名/族谱编号 文本包含、性别 单选枚举)、每页 20 条、行点击进详情。
|
||||
* 旧页的新增/删除按钮本就被注释禁用,故这里不出新增入口、也不做批量删除,行为对齐。
|
||||
*
|
||||
* 兼作「族员选择器」:family-tree-operation 的「族员搜索」带 ?channel=person 跳入,
|
||||
* 兼作「族员选择器」:首页 dashboard 详情态的「族员搜索」带 ?channel=person 跳入,
|
||||
* 单选回传后调用方按 PersonId 切换当前族员(useAdminPick / useAdminPickTarget 协议)。
|
||||
*/
|
||||
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
|
||||
@@ -13,7 +13,7 @@ import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { fillCriteria, makeFilterOption, toSelectOption } from "@/admin/admin-filter";
|
||||
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
|
||||
import { useAdminList, useAdminPickTarget, useAuthGuard, useThemeGradient, useThemeTokens } from "@/composables";
|
||||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||||
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
|
||||
import { FamilyPersonSearchCriteria } from "@/data/auto/familyPersonSearchCriteria";
|
||||
@@ -26,6 +26,16 @@ const { t, currentLocale } = useLocale();
|
||||
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
|
||||
const pickTarget = useAdminPickTarget();
|
||||
|
||||
// 统一风格接线(与族谱首页/媒体/共享页同套):主题渐变底 + 强调色 CSS 变量下发
|
||||
const { themeBackground } = useThemeGradient();
|
||||
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
|
||||
const themeVars = computed(() => ({
|
||||
"--fg-primary": primary.value,
|
||||
"--fg-gradient": accentGradient.value,
|
||||
"--fg-soft": accentSoft.value,
|
||||
"--fg-shadow": accentShadow.value,
|
||||
}));
|
||||
|
||||
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
|
||||
const isPick = ref(false);
|
||||
|
||||
@@ -167,7 +177,7 @@ useAuthGuard(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="[{ background: themeBackground }, themeVars]">
|
||||
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyPersonPage.title')"
|
||||
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyPersonEntity.PERSON_ID"
|
||||
title-key="GenealogyName" :selectable="isPick && pickTarget.multiple.value" :filter-count="filterCount"
|
||||
@@ -184,9 +194,42 @@ useAuthGuard(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 统一风格:主题渐变底由页面根节点承担,内嵌的 admin-list 透出背景 */
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
|
||||
.page :deep(.admin-list) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 工具条与导航融进渐变底,搜索/筛选做成白色圆角件(对齐首页面板观感) */
|
||||
.page :deep(.toolbar) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.page :deep(.filter-entry) {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(31, 35, 41, 0.08);
|
||||
}
|
||||
|
||||
.page :deep(.filter-entry.is-active) {
|
||||
color: var(--fg-primary, #2b8fff);
|
||||
background: var(--fg-soft, #eaf4ff);
|
||||
}
|
||||
|
||||
/* 卡片选中描边与勾选态跟主题色 */
|
||||
.page :deep(.card.is-selected) {
|
||||
box-shadow: inset 0 0 0 2rpx var(--fg-primary, #2b8fff);
|
||||
}
|
||||
|
||||
.page :deep(.card-check.is-on) {
|
||||
background-color: var(--fg-primary, #2b8fff);
|
||||
border-color: var(--fg-primary, #2b8fff);
|
||||
}
|
||||
|
||||
.page :deep(.action-toggle) {
|
||||
color: var(--fg-primary, #2b8fff);
|
||||
}
|
||||
</style>
|
||||
@@ -1,236 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 族谱查询(列表页):旧 WebVue views/master/FamilyTreeEnquiry.vue 的移动端等价物
|
||||
*
|
||||
* 保留:三条筛选(族谱名称/简介 文本包含、公开? 单选)、每页 20 条、
|
||||
* 行点击进详情、多选批量删除(旧页面同样是循环 Delete(TreeId))。
|
||||
* 丢掉:多列网格、列配置、打印。
|
||||
*
|
||||
* 挂在「安全管理」枢纽下(族谱属 master 域但按产品要求与权限体系同入口),
|
||||
* 未登记 PAGE_PERMISSIONS:路由级 fail-open,编辑能力由数据级 IsManager/IsCanAmend 把关。
|
||||
*/
|
||||
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { fillCriteria, makeFilterOption } from "@/admin/admin-filter";
|
||||
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
|
||||
import { FamilyTreeSearchCriteria } from "@/data/auto/familyTreeSearchCriteria";
|
||||
import { FamilyTreeViewEntity } from "@/data/auto/familyTreeViewEntity";
|
||||
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
|
||||
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
|
||||
import { usePagePermission } from "@/permission/permission-context";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import familyTreeService from "@/service/master/familyTreeService";
|
||||
|
||||
const { t, currentLocale } = useLocale();
|
||||
const perm = usePagePermission("familyTreeEnquiry");
|
||||
const pickTarget = useAdminPickTarget();
|
||||
|
||||
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
|
||||
const isPick = ref(false);
|
||||
|
||||
const LABEL_KEYS: Record<string, string> = {
|
||||
[FamilyTreeViewEntity.DB_TREE_NAME]: "FamilyTreePage.treeName",
|
||||
[FamilyTreeViewEntity.DB_BRIEF]: "FamilyTreePage.brief",
|
||||
[FamilyTreeViewEntity.DB_PUBLIC_INDC]: "FamilyTreePage.publicIndc",
|
||||
};
|
||||
|
||||
function yesNo() {
|
||||
return [
|
||||
{ label: t("FamilyTreePage.yes"), value: "Y" },
|
||||
{ label: t("FamilyTreePage.no"), value: "N" },
|
||||
];
|
||||
}
|
||||
|
||||
const bindOptions = reactive([
|
||||
makeFilterOption(
|
||||
FamilyTreeViewEntity.DB_TREE_NAME,
|
||||
t(LABEL_KEYS[FamilyTreeViewEntity.DB_TREE_NAME]),
|
||||
SearchFilterTypePolicy.TEXT,
|
||||
SearchFilterOperatorPolicy.CONTAIN,
|
||||
),
|
||||
makeFilterOption(
|
||||
FamilyTreeViewEntity.DB_BRIEF,
|
||||
t(LABEL_KEYS[FamilyTreeViewEntity.DB_BRIEF]),
|
||||
SearchFilterTypePolicy.TEXT,
|
||||
SearchFilterOperatorPolicy.CN_CONTAIN,
|
||||
),
|
||||
makeFilterOption(
|
||||
FamilyTreeViewEntity.DB_PUBLIC_INDC,
|
||||
t(LABEL_KEYS[FamilyTreeViewEntity.DB_PUBLIC_INDC]),
|
||||
SearchFilterTypePolicy.SINGLE_SELECTION,
|
||||
SearchFilterOperatorPolicy.SINGLE,
|
||||
yesNo(),
|
||||
),
|
||||
]);
|
||||
|
||||
async function fetchData(query: AdminQuery) {
|
||||
const criteria = new FamilyTreeSearchCriteria();
|
||||
criteria.GridColumns = [];
|
||||
fillCriteria(criteria, query.filters, query);
|
||||
const data = await familyTreeService.FindBySearchCriteria(criteria);
|
||||
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
|
||||
}
|
||||
|
||||
const state = useAdminList({
|
||||
bindOptions,
|
||||
loader: fetchData,
|
||||
orderby: FamilyTreeViewEntity.DB_TREE_NAME,
|
||||
});
|
||||
|
||||
const { filterCount, filterVisible, draft, searchToken } = state;
|
||||
|
||||
const columns = computed<AdminColumn[]>(() => [
|
||||
{
|
||||
label: t("FamilyTreePage.treeName"),
|
||||
prop: FamilyTreeViewEntity.TREE_NAME,
|
||||
},
|
||||
{
|
||||
label: t("FamilyTreePage.brief"),
|
||||
prop: FamilyTreeViewEntity.BRIEF,
|
||||
},
|
||||
{
|
||||
label: t("FamilyTreePage.publicIndc"),
|
||||
prop: FamilyTreeViewEntity.PUBLIC_INDC,
|
||||
format: (row: AdminRow) => {
|
||||
if (row.PublicIndc === "Y") {
|
||||
return t("FamilyTreePage.yes");
|
||||
}
|
||||
return row.PublicIndc === "N" ? t("FamilyTreePage.no") : "";
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("FamilyTreePage.ancestorSurname"),
|
||||
prop: FamilyTreeViewEntity.SURNAME,
|
||||
},
|
||||
{
|
||||
label: t("FamilyTreePage.ancestorGenealogyName"),
|
||||
prop: FamilyTreeViewEntity.GENEALOGY_NAME,
|
||||
},
|
||||
]);
|
||||
|
||||
const keyword = ref("");
|
||||
const listRef = ref<any>(null);
|
||||
|
||||
function onSearch(): void {
|
||||
const option = bindOptions.find(
|
||||
item => item.BindValue === FamilyTreeViewEntity.DB_TREE_NAME,
|
||||
);
|
||||
if (option) {
|
||||
option.Value = keyword.value;
|
||||
}
|
||||
state.reload();
|
||||
}
|
||||
|
||||
function onRowTap(row: AdminRow): void {
|
||||
if (isPick.value) {
|
||||
if (pickTarget.multiple.value) {
|
||||
return;
|
||||
}
|
||||
pickTarget.deliverOne(row);
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `${pageUrl("familyTreeOperation")}?id=${row.TreeId}&mode=view`,
|
||||
});
|
||||
}
|
||||
|
||||
function onCreate(): void {
|
||||
uni.navigateTo({ url: `${pageUrl("familyTreeOperation")}?mode=create` });
|
||||
}
|
||||
|
||||
function onConfirm(rows: AdminRow[]): void {
|
||||
if (!rows.length) {
|
||||
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
pickTarget.deliver(rows);
|
||||
}
|
||||
|
||||
async function onDelete(rows: AdminRow[]): Promise<void> {
|
||||
if (!rows.length) {
|
||||
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.showModal({
|
||||
title: t("AdminPage.confirmDeleteTitle"),
|
||||
success: async (res: any) => {
|
||||
if (!res.confirm) {
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
await familyTreeService.Delete(String(row.TreeId));
|
||||
}
|
||||
listRef.value?.clearSelection();
|
||||
state.reload();
|
||||
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(currentLocale, () => {
|
||||
for (const option of bindOptions) {
|
||||
const key = LABEL_KEYS[option.BindValue];
|
||||
if (key) {
|
||||
option.DisplayName = t(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onLoad((options) => {
|
||||
pickTarget.readOptions(options);
|
||||
isPick.value = String(options?.channel ?? "").trim().length > 0;
|
||||
});
|
||||
|
||||
useAuthGuard(() => {
|
||||
// 每次显示都重拉第一页:增/改/删后从详情返回能看到最新数据(navigateBack 不重新挂页面)
|
||||
nextTick(() => listRef.value?.refresh());
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyTreePage.title')"
|
||||
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyTreeViewEntity.TREE_ID"
|
||||
title-key="TreeName" :selectable="isPick ? pickTarget.multiple.value : Boolean(perm.canDelete.value)"
|
||||
:filter-count="filterCount" :filter-visible="filterVisible" :filter-items="draft"
|
||||
:search-token="searchToken" :keep-selection-on-reload="isPick" @search="onSearch"
|
||||
@open-filter="state.openFilter()" @close-filter="state.closeFilter()"
|
||||
@apply-filter="state.applyFilter($event)" @clear-filter="state.clearFiltersAndSearch()" @row-tap="onRowTap">
|
||||
<template v-if="!isPick && perm.canCreate.value" #toolbar-actions>
|
||||
<view class="create-entry" @click="onCreate">
|
||||
<u-icon name="plus" size="40" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #actions="{ rows }">
|
||||
<u-button v-if="isPick" type="primary" shape="circle" size="mini" :text="t('AdminPage.pickDoneBut')"
|
||||
@click="onConfirm(rows)" />
|
||||
<u-button v-else-if="perm.canDelete.value" type="error" shape="circle" size="mini"
|
||||
:text="t('AdminPage.deleteBut')" @click="onDelete(rows)" />
|
||||
</template>
|
||||
</admin-list>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
|
||||
.create-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
margin-left: 12rpx;
|
||||
color: $u-type-primary;
|
||||
background-color: $u-bg-color;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -257,18 +257,29 @@ async function onDelete(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 展览:把当前 kind + 专辑编号映射到对应播放器页(mode=view,非 list) */
|
||||
function onExhibit(): void {
|
||||
const pageName = kind.value === "photo"
|
||||
? "photoShow"
|
||||
: kind.value === "audio" ? "audioHall" : "videoHall";
|
||||
const name = album.value?.PublishName || "";
|
||||
/** kind -> 展厅页名 */
|
||||
function exhibitPageOf(k: PublishKind): "photoShow" | "audioHall" | "videoHall" {
|
||||
return k === "photo" ? "photoShow" : k === "audio" ? "audioHall" : "videoHall";
|
||||
}
|
||||
|
||||
/** 进对应展厅播放某专辑(mode=view + 专辑编号,非 list) */
|
||||
function exhibitAlbum(k: PublishKind, name: string): void {
|
||||
uni.navigateTo({
|
||||
url: `${pageUrl(pageName)}?mode=view&key=${encodeURIComponent(name)}`,
|
||||
url: `${pageUrl(exhibitPageOf(k))}?mode=view&key=${encodeURIComponent(name)}`,
|
||||
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
|
||||
});
|
||||
}
|
||||
|
||||
/** 展览:把当前 kind + 专辑编号映射到对应播放器页(mode=view,非 list) */
|
||||
function onExhibit(): void {
|
||||
exhibitAlbum(kind.value, album.value?.PublishName || "");
|
||||
}
|
||||
|
||||
/** 列表点卡片图标:不进编辑态,直接进对应展厅播放该专辑(.stop 阻断卡片点击) */
|
||||
function onIconExhibit(entity: PublishAlbumEntity): void {
|
||||
exhibitAlbum(kind.value, entity.PublishName || "");
|
||||
}
|
||||
|
||||
// ===== 明细:上移 / 下移 / 删除 =====
|
||||
function onMoveDetail(index: number, dir: -1 | 1): void {
|
||||
moveDetail(index, dir);
|
||||
@@ -346,7 +357,8 @@ function onRemoveGroup(familyName: string): void {
|
||||
<text class="media-page__state-text">{{ t("MediaPage.emptyAlbums") }}</text>
|
||||
</view>
|
||||
<view v-for="entity in list" :key="entity.PublishName" class="album-card" @tap="onOpenAlbum(entity)">
|
||||
<view class="album-card__avatar">
|
||||
<!-- 图标钮:单击直进展厅播放该专辑,.stop 不触发卡片进详情 -->
|
||||
<view class="album-card__avatar" @tap.stop="onIconExhibit(entity)">
|
||||
<u-icon :name="kindIcon(kind)" size="48" color="#ffffff" />
|
||||
</view>
|
||||
<view class="album-card__main">
|
||||
|
||||
@@ -24,12 +24,7 @@ function goSecurity(): void {
|
||||
uni.navigateTo({ url: pageUrl("securityPage") });
|
||||
}
|
||||
|
||||
/** 进入「族谱管理」列表页(/family-tree-enquiry) */
|
||||
function goFamilyTree(): void {
|
||||
uni.navigateTo({ url: pageUrl("familyTreeEnquiry") });
|
||||
}
|
||||
|
||||
/** 进入「家族成员」列表页(/family-person-enquiry) */
|
||||
/** 进入「家族成员」列表页(/family-person-enquiry);族谱管理已升为首页两态页,不再从这里入口 */
|
||||
function goFamilyPerson(): void {
|
||||
uni.navigateTo({ url: pageUrl("familyPersonEnquiry") });
|
||||
}
|
||||
@@ -54,14 +49,6 @@ function goFamilyPerson(): void {
|
||||
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
|
||||
</view>
|
||||
|
||||
<!-- 族谱管理:族谱列表与谱系图维护(/family-tree-enquiry) -->
|
||||
<view class="mine-cell" @tap="goFamilyTree">
|
||||
<text class="mine-cell__title">
|
||||
{{ t("MinePage.familyTree") }}
|
||||
</text>
|
||||
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
|
||||
</view>
|
||||
|
||||
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
|
||||
<view class="mine-cell" @tap="goFamilyPerson">
|
||||
<text class="mine-cell__title">
|
||||
|
||||
@@ -45,9 +45,7 @@ export const PAGE_PATHS = {
|
||||
// 帮助特性(短地址 /help-feature-enquiry、/help-feature-operation):旧 SecurityHelpFeature 两页迁移
|
||||
helpFeatureEnquiry: "pages/security/help-feature-enquiry",
|
||||
helpFeatureOperation: "pages/security/help-feature-operation",
|
||||
// 族谱管理(短地址 /family-tree-enquiry、/family-tree-operation):旧 master/FamilyTree 两页迁移,挂安全管理枢纽下
|
||||
familyTreeEnquiry: "pages/security/family-tree-enquiry",
|
||||
familyTreeOperation: "pages/security/family-tree-operation",
|
||||
// 族谱管理已融合进首页 dashboard 两态页,旧 family-tree-enquiry/operation 两页已退役(不在 PAGE_PATHS 登记)
|
||||
// 家族成员(短地址 /family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器
|
||||
familyPersonEnquiry: "pages/security/family-person-enquiry",
|
||||
familyPersonOperation: "pages/security/family-person-operation",
|
||||
|
||||
Reference in new issue
Block a user