ow
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user