This commit is contained in:
owenchen committed 2026-10-20 21:51:06 +08:00
1 parent 6417562c4f
commit f1e6e116c3
31 files changed
+2065 -309

No files matched your search

@@ -40,6 +40,20 @@ const background = reactive({
// 渐变色
backgroundImage: "linear-gradient(90deg, var(--u-type-primary-dark), var(--u-type-primary-disabled))",
});
/**
* 页面入场动画名:App 端改用淡入 fade,不用 slide-left。
* slide-left 的入场关键帧带 translate3d(40rpx, 0, 0):
* - 本项目多数 tab 页根节点是 position:fixed,而 fixed 遇到 transform 祖先会改以该祖先为包含块,
* 于是整页内容被这 40rpx 位移瞬时推出可视区右缘;
* - App 的 webview 会为此立刻显出页面级横向滚动条(贴在屏幕最底缘的一条细线,形似进度条),
* 动画结束又消失——这就是切 tab 时「底部闪一下」的来源;H5 由 CSS 控制滚动条、小程序无此机制,故只有 App 闪。
* H5/小程序保留原来的侧滑观感。
*/
let enterTransition = "slide-left";
// #ifdef APP-PLUS || APP-HARMONY
enterTransition = "fade";
// #endif
</script>
<template>
@@ -49,7 +63,7 @@ const background = reactive({
:is-fixed="true" :immersive="false" :custom-back="customBack" back-icon-name="arrow-leftward" title-width="350"
title-color="#ffffff" back-icon-color="#ffffff" />
<!-- #endif -->
<u-transition name="slide-left" :appear="true">
<u-transition :name="enterTransition" :appear="true">
<slot />
</u-transition>
<app-tabbar v-if="showTabbar" />
@@ -61,7 +75,14 @@ const background = reactive({
width: 100%;
min-height: 100vh;
// padding-bottom: 30rpx;
overflow-y: auto;
/*
* 原本这里是 overflow-y: auto,但它会让 .app-page 变成滚动容器,并按 CSS 规范把另一轴
* 的 visible 一并算成 auto —— 于是页面只要有瞬时横向位移(见上面 enterTransition 的注释)
* 就在底缘显出横向滚动条。该容器只有 min-height、高度随内容增长,本来就不会自己滚动,
* 纵向滚动交给页面/webview,因此改用 flow-root:既拿掉滚动容器,又保留原来的 BFC
* (避免子元素外边距穿透容器造成顶部/底部布局抖动)。
*/
display: flow-root;
background-color: $u-bg-white;
-webkit-font-smoothing: antialiased;
color: $u-main-color;
@@ -587,7 +587,13 @@ function close(): void {
flex-direction: row;
align-items: center;
justify-content: space-between;
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
/* #ifdef APP-PLUS */
padding: calc(28rpx + var(--status-bar-height, 0px)) 32rpx 28rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: calc(28rpx + env(safe-area-inset-top)) 32rpx 28rpx;
/* #endif */
background-color: #ffffff;
border-bottom: 2rpx solid #f2f3f5;
}
@@ -611,7 +617,12 @@ function close(): void {
/* 悬浮控制条:右侧竖排,白底圆角轻阴影;整条可拖拽(见 ctrlOffset) */
.ftc__ctrl {
position: absolute;
/* #ifdef APP-PLUS */
top: calc(120rpx + var(--status-bar-height, 0px));
/* #endif */
/* #ifndef APP-PLUS */
top: calc(120rpx + env(safe-area-inset-top));
/* #endif */
right: 24rpx;
z-index: 10;
display: flex;
@@ -37,6 +37,7 @@ import familyPersonService from "@/service/master/familyPersonService";
import familyTreeService from "@/service/master/familyTreeService";
import dmsFileService from "@/service/master/dmsFileService";
import { useLoginProfileStore } from "@/stores";
import { chooseFiles } from "@/utils/chooseFiles";
/** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */
const emit = defineEmits<{ (e: "ready"): void }>();
@@ -1334,22 +1335,50 @@ function clearLocalFile(): void {
localFileName.value = "";
}
/** uni.chooseFile 选本地 .fm 并读文本(H5 原生 File.text / App FileSystemManager),与服务器列表通道互斥 */
// #ifdef APP-PLUS
// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
/** App 侧读文本:plus.io FileReader(uni.getFileSystemManager 仅小程序可用,App 上调会 undefined) */
function readLocalText(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.file(
(file: any) => {
const reader = new plus.io.FileReader();
reader.onloadend = (event: any) => resolve(String(event.target.result ?? ""));
reader.onerror = (error: any) => reject(error);
reader.readAsText(file, "utf-8");
},
reject,
);
},
reject,
);
});
}
// #endif
/** 选本地 .fm 并读文本(H5 File.text / 小程序 FileSystemManager / App plus.io FileReader),与服务器列表通道互斥 */
async function pickLocalFile(): Promise<void> {
try {
const picked: any = await new Promise((resolve, reject) => {
uni.chooseFile({ count: 1, extension: [".fm"], success: resolve, fail: reject });
});
const file = picked.tempFiles?.[0];
const name = String(file?.name || picked.tempFilePaths?.[0]?.split("/").pop() || "exchange.fm");
// 选文件统一走 chooseFiles:uni.chooseFile 在 App 端不存在(官方仅 H5/小程序支持)
const picked = await chooseFiles(1, { extension: [".fm"] });
const file = picked[0];
if (!file) {
return;
}
let text = "";
// #ifdef H5
text = await file.text();
text = await (file.raw as File).text();
// #endif
// #ifndef H5
// #ifdef MP
const res: any = await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath: picked.tempFilePaths[0],
filePath: file.filePath,
encoding: "utf8",
success: resolve,
fail: reject,
@@ -1357,17 +1386,20 @@ async function pickLocalFile(): Promise<void> {
});
text = String(res.data ?? "");
// #endif
// #ifdef APP-PLUS
text = await readLocalText(file.filePath);
// #endif
if (!text.trim()) {
toast(t("FamilyTreePage.exchangeLocalBad"));
return;
}
localFileText.value = text;
localFileName.value = name;
localFileName.value = file.name;
pickedFileName.value = "";
}
catch (e: any) {
// 用户取消不算错(errMsg 含 cancel),其余给统一读失败提示
if (!String(e?.errMsg ?? e ?? "").includes("cancel")) {
if (!String(e?.errMsg ?? e?.message ?? e ?? "").includes("cancel")) {
toast(t("FamilyTreePage.exchangeLocalBad"));
console.warn("[familyTreeExchange] choose local file failed:", e);
}
@@ -2702,7 +2734,8 @@ defineExpose({
.tree-page__body--list {
display: block;
box-sizing: border-box;
padding: 20rpx;
/* 顶部让出系统状态栏(App 端 navigationStyle:custom 会顶到状态栏;H5 下变量为 0) */
padding: calc(20rpx + var(--status-bar-height, 0px)) 20rpx 20rpx;
}
/* ===== 顶部两张卡片:用户身份 + 网盘用量(白底圆角,与下方族谱面板同宽同调) ===== */
@@ -152,6 +152,11 @@ function onMouseDown(e: MouseEvent): void {
if (touchHandled) {
return;
}
// App / 小程序逻辑层没有 window:点按会合成 mousedown 进来,直接挂 window 监听会抛
// "Cannot read property 'addEventListener' of undefined",这两种端的拖动由 touch 轨负责
if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
return;
}
// 注意不能 preventDefault:它会取消后续原生 click 及其用户激活,onTap 就再也收不到点击
onDragStart(e.clientX, e.clientY);
const move = (ev: MouseEvent) => onDragMove(ev.clientX, ev.clientY);
@@ -103,7 +103,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_
z-index: 9;
display: flex;
align-items: center;
padding: 16rpx 24rpx;
/* App 端 navigationStyle:custom,内容会顶到系统状态栏:用 uni 注入的状态栏高度顶开(H5 下为 0,不影响) */
padding: calc(16rpx + var(--status-bar-height, 0px)) 24rpx 16rpx;
/* 透明顶栏:透出页面的浅蓝→白渐变底 */
background-color: transparent;
}
@@ -1105,7 +1105,13 @@ watch(isPlaying, () => (elapsed.value = 0));
/* 左上角返回(非沉浸态):避开状态栏/刘海,浮于舞台之上 */
.psp-back {
position: absolute;
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
/* #ifdef APP-PLUS */
top: calc(24rpx + var(--status-bar-height, 0px));
/* #endif */
/* #ifndef APP-PLUS */
top: calc(24rpx + env(safe-area-inset-top));
/* #endif */
left: 24rpx;
z-index: 9;
width: 72rpx;
@@ -51,6 +51,46 @@ function setControls(v: boolean): void {
}
watch(() => props.controls, v => (localControls.value = v ?? true));
/**
* App 端 <video> 是原生控件层:DOM 节点、cover-view 都会被整层盖住,
* 只有 plus 原生视图能画在画面上,所以左上角返回改用它绘制(H5 走模板里的 vp-back)。
*/
let backView: any = null;
function mountNativeBack(): void {
const ViewCtor = plus.nativeObj.View as any;
if (!ViewCtor) {
return;
}
// 原生视图不受页面布局影响,而 webview 是沉浸式的(从屏幕顶部开始):
// 自己在 24rpx 边距上叠加系统状态栏高度,与 H5 的 .vp-back 保持一致
const btn = Math.round(uni.upx2px(72)) || 41;
const gap = Math.round(uni.upx2px(24)) || 14;
const statusH = uni.getSystemInfoSync().statusBarHeight || 0;
backView = new ViewCtor("vp-native-back", {
top: `${gap + statusH}px`,
left: `${gap}px`,
width: `${btn}px`,
height: `${btn}px`,
// View 默认白底,透明后只剩下面绘制的内容
backgroundColor: "rgba(0,0,0,0)",
});
// 半透明圆底 + 白色 "<":uview 图标字体在 App 端只有 woff2(原生字体不支持),用系统字形 "‹" 代替;
// 与 H5 的 .vp-back / 音乐厅 .vap-back 同款:rgba(0,0,0,0.22) 圆底、无描边
backView.drawRect(
{ color: "rgba(0,0,0,0.22)", radius: `${btn / 2}px` },
{ top: "0px", left: "0px", width: `${btn}px`, height: `${btn}px` },
"vp-back-bg",
);
backView.drawText(
"‹",
{ top: "0px", left: "0px", width: `${btn}px`, height: `${btn}px` },
{ color: "#FFFFFF", size: `${Math.round(btn * 0.82)}px`, align: "center" },
"vp-back-glyph",
);
backView.addEventListener("click", () => emit("close"));
backView.show();
}
const isPlaying = ref(false);
const buffering = ref(false);
const currentTime = ref(0);
@@ -268,6 +308,11 @@ function loadCurrent(autoplay: boolean): void {
}
function toggle(): void {
// 控制区收起时,点画面先把控制条叫回来(App 端画面上的恢复按钮会被原生层盖住)
if (!controlsVisible.value) {
setControls(true);
return;
}
ensureCtx();
if (isPlaying.value) {
ctx?.pause();
@@ -623,6 +668,9 @@ const progressPercent = computed(() => (duration.value > 0 ? Math.min(100, (curr
const bufferPercent = computed(() => Math.min(100, Math.max(0, bufferedRatio.value * 100)));
onMounted(() => {
// #ifdef APP-PLUS
mountNativeBack();
// #endif
// #ifdef H5
const el = getInnerVideo();
nativeHlsOk.value = !!(el?.canPlayType
@@ -634,6 +682,12 @@ onMounted(() => {
nextTick(() => measureBar());
});
onBeforeUnmount(() => {
// #ifdef APP-PLUS
if (backView) {
backView.close();
backView = null;
}
// #endif
destroyHls();
if (sleepTimer) {
clearTimeout(sleepTimer);
@@ -657,7 +711,7 @@ watch(controlsVisible, (v) => {
<template>
<view class="vp">
<!-- 顶部:仅返回,绝对定位浮在画面上不占布局高度(列表入口在底部控制区 ≡) -->
<!-- 左上角返回:浮在画面上不占布局(App 端该位置被视频原生层盖住,由 plus 原生视图绘制,见 mountNativeBack) -->
<view v-if="controlsVisible" class="vp-back" @click="emit('close')">
<u-icon name="arrow-left" size="40" color="#ffffff" />
</view>
@@ -686,7 +740,7 @@ watch(controlsVisible, (v) => {
<u-icon name="play-right-fill" size="80" color="#ffffff" />
</view>
<!-- 收起控制区后的恢复入口 -->
<!-- 收起控制区后的恢复入口(H5;App 端该位置被视频层盖住,改为点画面恢复) -->
<view v-if="!controlsVisible" class="vp-restore" @click="toggleControls">
<u-icon name="eye" size="34" color="#ffffff" />
</view>
@@ -825,8 +879,15 @@ watch(controlsVisible, (v) => {
.vp-back {
position: absolute;
top: calc(16rpx + var(--status-bar-height));
left: 16rpx;
/* 与音乐厅 .vap-back 完全一致(同尺寸/同底色/无描边) */
/* App 端用 uni 注入的状态栏高度避让;H5 用 safe-area */
/* #ifdef APP-PLUS */
top: calc(24rpx + var(--status-bar-height, 0px));
/* #endif */
/* #ifndef APP-PLUS */
top: calc(24rpx + env(safe-area-inset-top));
/* #endif */
left: 24rpx;
z-index: 20;
display: flex;
align-items: center;
@@ -834,7 +895,7 @@ watch(controlsVisible, (v) => {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.28);
background: rgba(0, 0, 0, 0.22);
}
.vp-stage {
@@ -844,7 +905,8 @@ watch(controlsVisible, (v) => {
display: flex;
align-items: center;
justify-content: center;
background: #000;
/* 空态不铺黑:透出 .vp 的青绿渐变,与音乐厅同底色,半透明圆钮才看得见 */
background: transparent;
overflow: hidden;
}
@@ -913,12 +975,12 @@ watch(controlsVisible, (v) => {
top: 24rpx;
right: 24rpx;
z-index: 9;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(6, 56, 62, 0.5);
border: 2rpx solid rgba(255, 255, 255, 0.45);
}
@@ -18,8 +18,15 @@ export interface VinylTrack {
}
const props = withDefaults(
defineProps<{ tracks?: VinylTrack[]; controls?: boolean }>(),
{ tracks: () => [], controls: true },
defineProps<{
tracks?: VinylTrack[];
/** 打开时从第几首开始(文件页单击音频进音乐厅时指名) */
startIndex?: number;
/** 挂上源后是否立即播放(仅 App 生效:H5 自动播放受浏览器手势策略限制) */
autoPlay?: boolean;
controls?: boolean;
}>(),
{ tracks: () => [], startIndex: 0, autoPlay: false, controls: true },
);
const emit = defineEmits<{
(e: "close"): void;
@@ -27,7 +34,12 @@ const emit = defineEmits<{
}>();
const tracks = computed(() => props.tracks || []);
const index = ref(0);
/** 起始下标:未指名或越界(列表长度对不上)一律从第一首开始 */
function initialIndex(): number {
const start = props.startIndex || 0;
return start > 0 && start < (props.tracks?.length ?? 0) ? start : 0;
}
const index = ref(initialIndex());
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
@@ -585,6 +597,12 @@ onMounted(() => {
if (current.value) {
loadLrc(current.value.lrc);
}
// 文件页单击音频进音乐厅:App 内挂载即开播(H5 受浏览器自动播放策略限制,留给用户点播放)
// #ifndef H5
if (props.autoPlay && current.value) {
engPlay();
}
// #endif
});
onBeforeUnmount(() => {
if (sleepTimer) {
@@ -787,7 +805,13 @@ watch(() => tracks.value.length, (n) => {
/* 左上角返回(非沉浸态) */
.vap-back {
position: absolute;
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area(App 下该值为 0 不可靠) */
/* #ifdef APP-PLUS */
top: calc(24rpx + var(--status-bar-height, 0px));
/* #endif */
/* #ifndef APP-PLUS */
top: calc(24rpx + env(safe-area-inset-top));
/* #endif */
left: 24rpx;
z-index: 9;
width: 72rpx;