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

+35
View File
@@ -0,0 +1,35 @@
{
"version": "1",
"prompt": "template",
"title": "服务协议和隐私政策",
"message": "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。<br/>你可阅读<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept": "同意并接受",
"buttonRefuse": "暂不同意",
"hrefLoader": "default",
"backToExit": "false",
"second": {
"title": "确认提示",
"message": "进入应用前,你需先同意<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>,否则将退出应用。",
"buttonAccept": "同意并继续",
"buttonRefuse": "退出应用"
},
"disagreeMode": {
"support": false,
"loadNativePlugins": false,
"visitorEntry": false,
"showAlways": false
},
"styles": {
"backgroundColor": "#ffffff",
"borderRadius": "12px",
"title": {
"color": "#1f2937"
},
"buttonAccept": {
"color": "#ff6b00"
},
"buttonRefuse": {
"color": "#6b7280"
}
}
}
+101 -28
View File
@@ -7,7 +7,13 @@ import type {
import type { JwtInfo } from "@/data/auto/jwtInfo";
import { http } from "uview-pro";
import { markErrorNotified } from "@/common/global-error";
import { toBackendLocale } from "@/common/locale-const";
import {
currentUiLocale,
toBackendLocale,
UI_LOCALE_EN,
} from "@/common/locale-const";
import enUS from "@/locale/lang/en-US.json";
import zhCN from "@/locale/lang/zh-CN.json";
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
import {
currentRealPath,
@@ -16,6 +22,8 @@ import {
resolvePageName,
toRealPath,
} from "@/router/routes";
import { writeDownloadFile } from "@/utils/appFileWrite";
import { copyIntoAppUploadDir } from "@/utils/appUploadFile";
/**
* 全项目唯一请求层(基于 uview-pro httpPlugin,main.ts 已注册)
@@ -225,6 +233,28 @@ function throwRequestError(message: string, handled: boolean): never {
throw handled ? markErrorNotified(err) : err;
}
/**
* 后端固定英文短语 → 按界面语言重写。
* 登录失败后端只下发一句 certification(不区分账号不存在/密码错,防撞库探测),
* 但它的语言取决于请求体 Culture,而 Culture 取的是系统语言——
* 中文界面 + 英文系统的手机上会弹出英文正文,故在展示前统一本地化。
*/
const BACKEND_MESSAGE_LOCALE: Record<string, { zh: string; en: string }> = {
"The certification was not approved.": {
zh: zhCN.LoginForm.loginFailed,
en: enUS.LoginForm.loginFailed,
},
};
/** 把后端异常消息换成当前界面语言(未命中映射原样返回) */
function localizeBusinessMessage(message: string): string {
const hit = BACKEND_MESSAGE_LOCALE[message.trim()];
if (!hit) {
return message;
}
return currentUiLocale() === UI_LOCALE_EN ? hit.en : hit.zh;
}
/**
* 业务异常识别:HTTP 成功但 body 是 .NET 异常对象时命中
* @returns 命中返回异常消息(调用方需抛出),未命中返回空串
@@ -232,10 +262,10 @@ function throwRequestError(message: string, handled: boolean): never {
function detectBusinessException(data: any): string {
if (!data || typeof data !== "object" || !data.SourceCode)
return "";
const msg = extractErrorMessage(data) || "业务处理失败";
const msg = localizeBusinessMessage(extractErrorMessage(data) || "业务处理失败");
if (data.SourceCode === "CloudBuilder.Core.ValidatedException") {
uni.showModal({
title: "业务校验失败",
title: currentUiLocale() === UI_LOCALE_EN ? "Validation failed" : "业务校验失败",
content: msg.slice(0, 200),
showCancel: false,
});
@@ -466,20 +496,15 @@ function xhrUploadFile(
}
// #endif
/** 上传文件(自动携带 token,失败 reject 可感知)。
* 后端 PackUploadDmsFile 对 multipart filename 执行 Split('-') 取下标,要求 `uid-序号-总数.ext`
* 三段式(单片 -1-1),否则抛「Index was outside the bounds of the array」;而 uni.uploadFile
* 无法定制 filename,故 H5 改走 XHR + 手写 FormData(与文件页上传队列同款),App 端仍用 uni.uploadFile。 */
export function uploadFile(options: UploadOptions): Promise<any> {
const { url, filePath, name = "file", fileName, formData, header = {} } = options;
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
const auth = bearerAuth();
// #ifdef H5
return xhrUploadFile(fullUrl, filePath, name, fileName || buildChunkFileName(name), formData, header, auth);
// #endif
// #ifndef H5
/** 非 H5 通道:uni.uploadFile 直发(App 侧进门前已把 filePath 准备成协议物理名) */
function sendByUniUpload(
fullUrl: string,
filePath: string,
name: string,
formData: Record<string, any> | undefined,
header: Record<string, any>,
auth: string,
): Promise<any> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: fullUrl,
@@ -497,6 +522,32 @@ export function uploadFile(options: UploadOptions): Promise<any> {
},
});
});
}
/** 上传文件(自动携带 token,失败 reject 可感知)。
* 后端 PackUploadDmsFile 对 multipart filename 执行 Split('-') 取下标,要求 `uid-序号-总数.ext`
* 三段式(单片 -1-1),否则抛「Index was outside the bounds of the array」;而 uni.uploadFile
* 无法定制 filename:H5 改走 XHR + 手写 FormData(与文件页上传队列同款),App 端先把文件复制成
* 协议物理名再交给 uni.uploadFile(basename 即 filename,头像/身份证/网盘共用同一落盘口径)。 */
export function uploadFile(options: UploadOptions): Promise<any> {
const { url, filePath, name = "file", fileName, formData, header = {} } = options;
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
const auth = bearerAuth();
// #ifdef H5
return xhrUploadFile(fullUrl, filePath, name, fileName || buildChunkFileName(name), formData, header, auth);
// #endif
// #ifndef H5
// #ifdef APP-PLUS
return copyIntoAppUploadDir(filePath, fileName || buildChunkFileName(name))
.then(targetPath => sendByUniUpload(fullUrl, targetPath, name, formData, header, auth))
// 复制失败仍按原路径发一次:至少把后端真实报错带回来,而不是前端吞掉
.catch(() => sendByUniUpload(fullUrl, filePath, name, formData, header, auth));
// #endif
// #ifndef APP-PLUS
return sendByUniUpload(fullUrl, filePath, name, formData, header, auth);
// #endif
// #endif
}
@@ -525,7 +576,7 @@ function abToString(buffer: ArrayBuffer): string {
result += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
i += 2;
}
else {
else if (c < 0xF0) {
result += String.fromCharCode(
((c & 0x0F) << 12)
| ((bytes[i + 1] & 0x3F) << 6)
@@ -533,6 +584,18 @@ function abToString(buffer: ArrayBuffer): string {
);
i += 3;
}
else {
// 4 字节码点(生僻字/emoji)要拆成代理对,否则提示消息乱码
const point = ((c & 0x07) << 18)
| ((bytes[i + 1] & 0x3F) << 12)
| ((bytes[i + 2] & 0x3F) << 6)
| (bytes[i + 3] & 0x3F);
result += String.fromCharCode(
0xD800 + ((point - 0x10000) >> 10),
0xDC00 + ((point - 0x10000) & 0x3FF),
);
i += 4;
}
}
return result;
}
@@ -600,7 +663,7 @@ export async function requestBuffer(options: {
return res.data as ArrayBuffer;
}
/** 字节按指定文件名落盘:H5 走 blob+<a>;小程序/App 写本地目录后调起系统文档预览 */
/** 字节按指定文件名落盘:H5 走 blob+<a>;小程序写本地目录、App 走 Native.js 落盘后调起系统文档预览 */
export async function saveArrayBuffer(bytes: ArrayBuffer, fileName: string): Promise<void> {
// #ifdef H5
// H5:blob + 隐藏 <a> 触发浏览器下载
@@ -618,17 +681,26 @@ export async function saveArrayBuffer(bytes: ArrayBuffer, fileName: string): Pro
// #endif
// #ifndef H5
// 小程序/App:写入本地用户目录后调起系统文档预览
const fs = uni.getFileSystemManager();
// 写入目录按平台区分:微信用 uni.env.USER_DATA_PATH(官方类型未声明故转 any);
// App 端无 uni.env,写 plus 私有目录 _doc/(openDocument 同样支持该相对路径)
let userDataPath = "";
// #ifdef MP-WEIXIN
userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
// #endif
// #ifdef APP-PLUS
userDataPath = "_doc";
// App 没有 uni.getFileSystemManager(仅小程序可用):
// Android 落公共「下载/有家啦」目录(文件管理器可见),iOS/降级落应用私有目录;
// 落盘后再 openDocument 调起系统预览,打不开不算失败——文件已经保存好了
const saved = writeDownloadFile(bytes, fileName);
await new Promise<void>((resolve) => {
uni.openDocument({
filePath: saved.path,
showMenu: true,
success: () => resolve(),
fail: () => resolve(),
});
});
return;
// #endif
// #ifndef APP-PLUS
// 小程序:写入本地用户目录后调起系统文档预览
const fs = uni.getFileSystemManager();
// 写入目录用 uni.env.USER_DATA_PATH(官方类型未声明故转 any)
const userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
const filePath = `${userDataPath}/${fileName}`;
await new Promise<void>((resolve, reject) => {
fs.writeFile({
@@ -647,6 +719,7 @@ export async function saveArrayBuffer(bytes: ArrayBuffer, fileName: string): Pro
});
});
// #endif
// #endif
}
/**
@@ -31,3 +31,20 @@ export function toUiLocale(value: string): string {
const l = String(value || "").toLowerCase();
return l.startsWith("en") ? UI_LOCALE_EN : UI_LOCALE_ZH;
}
/**
* 当前界面语言包 name(uView 切换结果)。
* 给非组件上下文用(拦截器、工具函数):那些地方拿不到 useLocale(),
* 也不能用 uni.getLocale()——它读的是系统语言,界面切了中文它仍可能是 en。
*/
let currentUiLocaleName = DEFAULT_LOCALE;
/** 切换界面语言时同步(目前入口在登录页 toggleLang) */
export function setCurrentUiLocale(value: string): void {
currentUiLocaleName = toUiLocale(value);
}
/** 取当前界面语言包 name(默认中文) */
export function currentUiLocale(): string {
return currentUiLocaleName;
}
@@ -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;
@@ -35,6 +35,8 @@ export interface SlideItem {
/** 音轨项 */
export interface TrackItem {
url: string;
/** 列表模式下的文件主键:文件页单击进厅时按它定位起始曲目 */
id?: number;
pic?: string;
title?: string;
lrc?: string;
@@ -44,6 +46,8 @@ export interface TrackItem {
/** 视频项:url=原码,m3u8=转码 HLS */
export interface VideoItem {
url: string;
/** 列表模式下的文件主键:文件页单击进厅时按它定位起始片源 */
id?: number;
m3u8?: string;
pic?: string;
title?: string;
@@ -118,6 +122,7 @@ export function useMediaShow() {
const rows = await listRows(FileTypePolicy.AUDIO, key);
return rows.map((r: any) => ({
url: r.Url,
id: r.FileId,
pic: r.FormattedPic || "",
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
@@ -142,6 +147,7 @@ export function useMediaShow() {
const rows = await listRows(FileTypePolicy.VIDEO, key);
return rows.map((r: any) => ({
url: r.Url,
id: r.FileId,
m3u8: r.FormattedM3u8 || "",
pic: r.FormattedPic || "",
title: r.OrgFileName || baseName(r.Url),
+3 -1
View File
@@ -22,7 +22,8 @@
"brandName": "Family Tree",
"slogan": "One tree, generations of us",
"langZh": "简体中文",
"langEn": "English"
"langEn": "English",
"loginFailed": "Incorrect account or password"
},
"TabBar": {
"home": "Home",
@@ -646,6 +647,7 @@
"publishDone": "Published",
"downloadNoFile": "No downloadable files",
"downloadDone": "Downloaded",
"downloadDoneApp": "Saved to Phone storage/Download/有家啦",
"downloading": "Downloading…",
"previewNoImage": "No previewable images",
"loadingMedia": "Preparing media…",
+4 -2
View File
@@ -22,7 +22,8 @@
"brandName": "有家族谱",
"slogan": "一棵树,几代人",
"langZh": "简体中文",
"langEn": "English"
"langEn": "English",
"loginFailed": "账号或密码错误"
},
"TabBar": {
"home": "首页",
@@ -646,6 +647,7 @@
"publishDone": "发布成功",
"downloadNoFile": "没有可下载的文件",
"downloadDone": "下载完成",
"downloadDoneApp": "已保存到 手机存储/Download/有家啦",
"downloading": "下载中…",
"previewNoImage": "没有可预览的图片",
"loadingMedia": "媒体准备中…",
@@ -894,4 +896,4 @@
"r404_3": "页面已下线或改名,收藏夹里还是旧地址",
"r404_4": "服务器刚重启、菜单还没加载出来,刷新一次即可"
}
}
}
+154 -103
View File
@@ -1,108 +1,159 @@
{
"name": "有家相册",
"appid": "__UNI__YJL0001",
"description": "有家相册:家庭相册、家族树与文档共享的跨端应用",
"versionName": "1.0.0",
"versionCode": "1",
"transformPx": false,
"uniStatistics": {
"enable": false
},
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "有家相册",
"appid" : "__UNI__F0B0BDF",
"description" : "有家相册:家庭相册、家族树与文档共享的跨端应用",
"versionName" : "1.0.0",
"versionCode" : "1",
"transformPx" : false,
"uniStatistics" : {
"enable" : false
},
"safearea": {
"background": "#ffffff",
"backgroundDark": "#2f0508",
"bottom": {
"offset": "none"
}
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"safearea" : {
"background" : "#ffffff",
"backgroundDark" : "#2f0508",
"bottom" : {
"offset" : "none"
}
},
"modules" : {
"VideoPlayer" : {}
},
"privacy" : {
"prompt" : "template",
"template" : {
"title" : "服务协议和隐私政策",
"message" : "感谢你使用“有家相册”。我们非常重视你的个人信息保护,请你务必审慎阅读、充分理解《服务协议》和《隐私政策》各条款,包括但不限于:为向你提供账号注册登录、家庭相册、家族谱系与文件共享服务,我们会在你授权同意后收集你的手机号、头像、身份证照片(仅实名核验用)、你主动上传的照片/视频/文件以及必要的设备标识与操作日志等信息。<br/>你可阅读<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"second" : {
"title" : "确认提示",
"message" : "进入应用前,你需先同意<a href=\"static/legal/service-agreement.html\">《服务协议》</a>和<a href=\"static/legal/privacy-policy.html\">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
}
}
},
"distribute" : {
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>"
]
},
"ios" : {
"dSYMs" : false,
"privacyDescription" : {
"NSCameraUsageDescription" : "用于拍摄头像、身份证照片及上传家庭相册中的照片和视频",
"NSPhotoLibraryUsageDescription" : "用于从相册选择头像、身份证照片及上传照片和视频",
"NSPhotoLibraryAddUsageDescription" : "用于将照片或视频保存到系统相册"
}
},
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
}
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
"h5" : {
"router" : {
"mode" : "history",
"base" : "/"
},
"devServer" : {
"host" : "::",
"port" : 5173
},
"darkmode" : true,
"themeLocation" : "theme.json"
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true,
"darkmode" : true,
"themeLocation" : "theme.json"
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"vueVersion" : "3",
"app-harmony" : {
"darkmode" : true,
"themeLocation" : "theme.json",
"safearea" : {
"background" : "#ffffff",
"backgroundDark" : "#2f0508",
"bottom" : {
"offset" : "none"
}
},
"distribute" : {
"bundleName" : "com.youjiala.app",
"icons" : {
"foreground" : "unpackage/res/icons/1024x1024.png",
"background" : "unpackage/res/icons/1024x1024.png"
},
"splashScreens" : {
"startWindowBackground" : "#FFFFFF",
"startWindowIcon" : "unpackage/res/icons/1024x1024.png"
},
"signingConfigs" : {}
}
}
},
"h5": {
"router": {
"mode": "history",
"base": "/"
},
"devServer": {
"host": "::",
"port": 5173
},
"darkmode": true,
"themeLocation": "theme.json"
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"vueVersion": "3",
"app-harmony": {
"darkmode": true,
"themeLocation": "theme.json",
"safearea": {
"background": "#ffffff",
"backgroundDark": "#2f0508",
"bottom": {
"offset": "none"
}
},
"distribute": {
"bundleName": "com.youjiala.app",
"icons": {
"foreground": "unpackage/res/icons/1024x1024.png",
"background": "unpackage/res/icons/1024x1024.png"
},
"splashScreens": {
"startWindowBackground": "#FFFFFF",
"startWindowIcon": "unpackage/res/icons/1024x1024.png"
},
"signingConfigs": {}
}
}
}
+11 -1
View File
@@ -172,7 +172,17 @@
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "@navTxtStyle",
"navigationBarTitleText": "有家相册",
"navigationStyle": "custom"
"navigationStyle": "custom",
// App 端页面级滚动条策略:none 后 webview 不再显出滚动条。
// 切 tab 走 reLaunch 重建页面,入场瞬间页面内容可能有 1~2px 的横向溢出(四舍五入、
// 带 transform 的动画层等),Android/iOS 就会在屏幕最底缘闪一条形似进度条的原生滚动条;
// H5 的滚动条由 CSS 掌控、小程序无此机制,所以只在 app-plus / app-harmony 上关
"app-plus": {
"scrollIndicator": "none"
},
"app-harmony": {
"scrollIndicator": "none"
}
},
"easycom": {
"autoscan": true
+6 -1
View File
@@ -9,6 +9,7 @@ import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { brandGlyph, veinGlyph } from "@/common/brand";
import { backendLocale, baseUrl } from "@/common/http.interceptor";
import { DEFAULT_LOCALE, setCurrentUiLocale } from "@/common/locale-const";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { useThemeGradient, useThemeTokens } from "@/composables";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
@@ -58,12 +59,16 @@ onLoad((options) => {
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
const isZh = computed(() => (currentLocale.value?.name ?? "zh-CN") !== "en-US");
// 非组件上下文(请求拦截器)拿不到 useLocale,把当前界面语言登记到全局
setCurrentUiLocale(currentLocale.value?.name ?? DEFAULT_LOCALE);
/** 非发布构建才显示「当前环境 + 实际调用的后端地址」:发布版 import.meta.env.PROD 为 true,整行编译期即被剔除 */
const isProd = import.meta.env.PROD;
const envMode = import.meta.env.MODE;
function toggleLang() {
setLocale(isZh.value ? "en-US" : "zh-CN");
const next = isZh.value ? "en-US" : "zh-CN";
setLocale(next);
setCurrentUiLocale(next);
// 开发构建顺手重拉数据库翻译:改完 DB 词条点一下切换就能看到新值,无需整页刷新
// (生产构建 import.meta.env.DEV 为 false,该分支编译期即被剔除)
if (import.meta.env.DEV) {
@@ -3,6 +3,7 @@
* 音乐厅页(对齐旧 WebVue views/master/PreviewAudio.vue):
* 黑胶唱机播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录音频;
* 或 key=<专辑编号> 播放某音乐专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 文件页单击某首音频进来时额外带 start=<FileId>,定位到该曲并从它开始播。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸(H5 元素全屏在组件内处理)。
*/
import type { VinylTrack } from "@/components/vinyl-audio-player/vinyl-audio-player.vue";
@@ -16,62 +17,81 @@ const tracks = ref<VinylTrack[]>([]);
const controls = ref(true);
const loading = ref(true);
const errorText = ref("");
/** 起始曲目下标与是否开播:只有指名了 start 才自动播(专辑展览进来保持手动) */
const startIndex = ref(0);
const autoStart = ref(false);
function closeMe(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
try {
const items = await loadTracks(mode, key);
tracks.value = items.map(t => ({
name: t.name || t.title || "",
author: "有家相册",
url: t.url,
cover: t.pic || undefined,
lrc: t.lrc || undefined,
}));
} catch (error: any) {
errorText.value = error?.message || "加载失败";
} finally {
loading.value = false;
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
const startId = Number(query?.start || 0);
try {
const items = await loadTracks(mode, key);
tracks.value = items.map(t => ({
name: t.name || t.title || "",
author: "有家相册",
url: t.url,
cover: t.pic || undefined,
lrc: t.lrc || undefined,
}));
// 文件页单击进来:按 FileId 定位(同名文件也不会认错),找不到就从第一首开始(不自动播)
const hit = startId ? items.findIndex(t => t.id === startId) : -1;
if (hit >= 0) {
startIndex.value = hit;
autoStart.value = true;
}
}
catch (error: any) {
errorText.value = error?.message || "加载失败";
}
finally {
loading.value = false;
}
});
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<vinyl-audio-player v-else v-model:controls="controls" :tracks="tracks" @close="closeMe" />
</app-page>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<vinyl-audio-player
v-else
v-model:controls="controls"
:tracks="tracks"
:start-index="startIndex"
:auto-play="autoStart"
@close="closeMe"
/>
</app-page>
</template>
<style lang="scss" scoped>
.show-loading {
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
}
.show-loading__text {
font-size: 28rpx;
color: #9aa0a6;
font-size: 28rpx;
color: #9aa0a6;
}
</style>
@@ -3,6 +3,7 @@
* 影视厅页(对齐旧 WebVue views/master/PreviewVideo.vue):
* 自绘控制播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录视频;
* 或 key=<专辑编号> 播放某影视专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 文件页单击某个视频进来时额外带 start=<FileId>,定位到该片源并从它开始播。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸。
*/
import type { VideoSource } from "@/components/video-player/video-player.vue";
@@ -16,61 +17,80 @@ const videos = ref<VideoSource[]>([]);
const controls = ref(true);
const loading = ref(true);
const errorText = ref("");
/** 起始片源下标与是否开播:只有指名了 start 才自动播(专辑展览进来保持手动) */
const startIndex = ref(0);
const autoStart = ref(false);
function closeMe(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
try {
const items = await loadVideos(mode, key);
videos.value = items.map(v => ({
name: v.name || v.title || "",
url: v.url,
m3u8: v.m3u8,
pic: v.pic,
}));
} catch (error: any) {
errorText.value = error?.message || "加载失败";
} finally {
loading.value = false;
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
const startId = Number(query?.start || 0);
try {
const items = await loadVideos(mode, key);
videos.value = items.map(v => ({
name: v.name || v.title || "",
url: v.url,
m3u8: v.m3u8,
pic: v.pic,
}));
// 文件页单击进来:按 FileId 定位,找不到就从第一部开始(不自动播)
const hit = startId ? items.findIndex(v => v.id === startId) : -1;
if (hit >= 0) {
startIndex.value = hit;
autoStart.value = true;
}
}
catch (error: any) {
errorText.value = error?.message || "加载失败";
}
finally {
loading.value = false;
}
});
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<video-player v-else v-model:controls="controls" :videos="videos" @close="closeMe" />
</app-page>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<video-player
v-else
v-model:controls="controls"
:videos="videos"
:start-index="startIndex"
:auto-play="autoStart"
@close="closeMe"
/>
</app-page>
</template>
<style lang="scss" scoped>
.show-loading {
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
}
.show-loading__text {
font-size: 28rpx;
color: #9aa0a6;
font-size: 28rpx;
color: #9aa0a6;
}
</style>
@@ -215,7 +215,12 @@ async function doDownload(file: FileItem): Promise<void> {
}
await withBusy(async () => {
await saveFileBytes(await fetchFileBytes(record));
// #ifdef APP-PLUS
toast(t("FilePage.downloadDoneApp"));
// #endif
// #ifndef APP-PLUS
toast(t("FilePage.downloadDone"));
// #endif
}, t("FilePage.downloading"));
}
+24 -4
View File
@@ -192,6 +192,16 @@ function goShowWithFolder(name: "photoShow" | "audioHall" | "videoHall"): void {
});
}
/** 单击音频/视频:进音乐厅/影视厅,start 带 FileId 让厅内定位到该条并开播 */
function openInHall(file: FileItem): void {
const folder = pathStack.value[pathStack.value.length - 1]?.value ?? "";
const hall = file.kind === "audio" ? "audioHall" : "videoHall";
uni.navigateTo({
url: `${pageUrl(hall)}?mode=list&key=${encodeURIComponent(folder)}&start=${file.id}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
function onQuickClick(key: string): void {
// 回收站/共享大厅(pages/trash、pages/share-hall);相册/音乐厅/影视厅展览当前目录
if (key === "trash") {
@@ -313,12 +323,12 @@ const previewIcon = computed(() => {
return pickedKind.value === "audio" ? "volume" : "eye";
});
/** 预览按钮的分流动作 key:视频/音频走播放页(play),图片走相册连览(preview);
* 文档没有移动端预览器,不显示该按钮 */
/** 预览按钮的分流动作 key:视频/音频进音乐厅/影视厅(hall,与单击条目同一条路径),
* 图片走相册连览(preview);文档没有移动端预览器,不显示该按钮 */
const previewKey = computed(() => {
const kind = pickedKind.value;
if (kind === "video" || kind === "audio") {
return "play";
return "hall";
}
return kind === "image" ? "preview" : "";
});
@@ -388,7 +398,9 @@ async function onOpen(file: FileItem): Promise<void> {
actionPreview([file]);
}
else if (file.kind === "video" || file.kind === "audio") {
actionPlay(file);
// 单击即进音乐厅/影视厅(以当前目录为播放列表),从这一条开始播;
// 「…」面板里的试听/播放仍走单文件播放页(actionPlay)
openInHall(file);
}
else {
// 文档没有移动端预览器,点开按下载处理(比占位 toast 实用)
@@ -572,7 +584,12 @@ async function actionDownload(targets: FileItem[]): Promise<void> {
await saveFileBytes(await fetchFileBytes(record));
}
}
// #ifdef APP-PLUS
toast(t("FilePage.downloadDoneApp"));
// #endif
// #ifndef APP-PLUS
toast(t("FilePage.downloadDone"));
// #endif
}, t("FilePage.downloading"));
}
@@ -830,6 +847,9 @@ const actionRunners: Record<string, (targets: FileItem[]) => void> = {
share: actionShare,
publish: actionPublish,
preview: actionPreview,
// 多选栏的「视频播放/音乐播放」:与单击条目一致,进影视厅/音乐厅并从该条开播;
// 面板(file-action-sheet)的同名动作仍走 play 单文件播放页
hall: targets => targets[0] && openInHall(targets[0]),
play: targets => targets[0] && actionPlay(targets[0]),
detail: targets => targets[0] && actionDetail(targets[0]),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,213 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>有家相册隐私政策</title>
<style>
:root {
color-scheme: light dark;
}
body {
margin: 0;
padding: 20px 16px 48px;
font-size: 15px;
line-height: 1.75;
font-family:
-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei",
sans-serif;
color: #1f2937;
background: #ffffff;
word-break: break-word;
}
h1 {
font-size: 20px;
margin: 0 0 4px;
color: #111827;
}
h2 {
font-size: 16px;
margin: 28px 0 8px;
color: #ff6b00;
}
p {
margin: 8px 0;
}
ul {
margin: 8px 0;
padding-left: 20px;
}
li {
margin: 4px 0;
}
.meta {
color: #6b7280;
font-size: 13px;
margin-bottom: 16px;
}
.tip {
background: #fff7ed;
border-left: 3px solid #ff6b00;
padding: 8px 12px;
border-radius: 4px;
margin: 12px 0;
font-size: 14px;
}
a {
color: #ff6b00;
}
@media (prefers-color-scheme: dark) {
body {
color: #e5e7eb;
background: #181818;
}
h1 {
color: #f3f4f6;
}
.meta {
color: #9ca3af;
}
.tip {
background: #2a1a0d;
}
}
</style>
</head>
<body>
<h1>有家相册隐私政策</h1>
<p class="meta">更新日期:2026年10月20日 | 生效日期:2026年10月20日</p>
<p>
「有家相册」(以下简称“本应用”)由有家相册开发团队(以下简称“我们”)开发并运营。我们深知个人信息对你的重要性,将按照《中华人民共和国个人信息保护法》《网络安全法》等法律法规的要求,采取相应的安全保护措施,尽力保护你的个人信息安全可控。
</p>
<div class="tip">
请你在使用本应用前,仔细阅读并充分理解本政策。你点击弹窗中的“同意并接受”按钮,即表示你已阅读并同意本政策的全部内容。
</div>
<h2>一、我们收集的信息</h2>
<p>我们仅在你使用具体功能时,按照“最小必要”原则收集以下信息:</p>
<ul>
<li>
<strong>账号信息</strong
>:注册与登录时你主动提供手机号并设置密码。密码经加密处理(前端摘要 +
RSA 加密)后传输与存储,我们不会以明文形式保存你的密码。
</li>
<li>
<strong>个人资料</strong
>:你可选填的头像、昵称。头像从相册选择或拍摄后上传至服务器。
</li>
<li>
<strong>实名认证信息</strong
>:当你主动发起身份认证时,我们会收集你拍摄或选择的身份证照片,仅用于核验你的真实身份,不会用于其他目的,也不会向无关第三方提供。
</li>
<li>
<strong>家庭相册与族谱数据</strong
>:你或家族管理员录入的家族成员姓名、称呼、生日、亲属关系,以及你主动上传的照片、视频、音频、文档等文件。上述内容仅在你的家族成员范围内按你设定的权限可见。
</li>
<li>
<strong>设备与日志信息</strong
>:为保障账号与数据安全、排查故障,我们会收集设备型号、操作系统版本、设备标识符(如
OAID、Android ID)、IP 地址、操作日志与崩溃日志。
</li>
</ul>
<h2>二、系统权限调用说明</h2>
<p>
本应用仅在实现对应功能时申请以下权限,你可以随时在系统设置中关闭,关闭后将无法使用对应功能,但不影响其他功能的使用:
</p>
<ul>
<li><strong>相机</strong>:拍摄头像、身份证照片或上传照片、视频。</li>
<li>
<strong>相册(读写)</strong
>:从相册选择图片视频上传;将下载的照片、视频保存到系统相册。
</li>
<li><strong>存储</strong>:缓存与保存你上传、下载的家庭共享文件。</li>
<li>
<strong>网络状态(WiFi/移动网络)</strong
>:判断网络连接状态,优化文件上传下载体验。
</li>
<li><strong>震动</strong>:操作反馈提示。</li>
</ul>
<h2>三、我们如何使用信息</h2>
<ul>
<li>提供账号注册、登录与安全校验服务;</li>
<li>提供家庭相册、族谱管理、文件共享与成员权限管理服务;</li>
<li>在你发起实名认证时完成身份核验;</li>
<li>保障服务安全稳定运行,预防、发现和处置账号安全与数据安全风险;</li>
<li>在你同意的前提下,改进产品功能与使用体验。</li>
</ul>
<p>
我们不会将收集的信息用于本政策未载明的其他用途;如需变更用途,将再次征得你的明示同意。
</p>
<h2>四、信息的共享、转让与公开披露</h2>
<ul>
<li>
<strong>家族内共享</strong
>:你上传的照片、文件及录入的族谱信息,会按家族管理员设定的权限在你的家族成员范围内共享。请你在上传前确认相关内容已获得涉及成员的同意。
</li>
<li>
<strong>第三方 SDK</strong>:本应用基于 DCloud uni-app
引擎开发。为提供统计分析、崩溃排查与持续优化服务,引擎会在运行过程中采集应用启动时间、异常错误日志及设备唯一识别码(如
Android ID/OAID)。相关信息详见《DCloud App
引擎隐私政策》(https://dcloud.io/license/appprivacy.html)。我们已关闭独立的第三方统计服务。
</li>
<li>
<strong>不做的事</strong
>:我们不会向任何第三方出售你的个人信息;不会在未征得你同意的情形下向无关第三方共享、转让你的个人信息,法律法规另有规定的除外。
</li>
</ul>
<h2>五、信息的存储与保护</h2>
<ul>
<li>你的个人信息存储于中华人民共和国境内的服务器。</li>
<li>传输环节采用 HTTPS 加密,密码等敏感字段额外加密处理。</li>
<li>我们通过访问权限控制、数据备份等措施保护你的信息安全。</li>
<li>
存储期限:在你使用本应用期间持续保存;你删除相关内容或注销账号后,我们将在法律法规允许的期限内删除或匿名化处理。
</li>
</ul>
<h2>六、你的权利</h2>
<ul>
<li>
<strong>查阅与更正</strong
>:你可以在应用内查看并修改你的资料与家庭信息。
</li>
<li>
<strong>删除</strong
>:你可以在应用内删除你上传的照片、文件等内容(回收站清空后不可恢复)。
</li>
<li>
<strong>撤回同意</strong
>:你可以在系统设置中关闭相关权限,或通过设置页修改隐私选项。
</li>
<li>
<strong>注销账号</strong
>:你可以联系管理员申请注销账号,注销后你的个人信息将被删除或匿名化处理。
</li>
</ul>
<h2>七、未成年人保护</h2>
<p>
本应用主要面向成年人。若你是未满 14
周岁的未成年人,请在监护人陪同下阅读本政策,并在取得监护人同意后使用本应用。我们不会主动收集未成年人的个人信息;如发现在未获监护人同意的情况下收集了未成年人的个人信息,我们会尽快删除相关数据。
</p>
<h2>八、本政策的变更</h2>
<p>
我们可能适时修订本政策。当收集信息的目的、方式、范围发生重大变化时,我们会通过应用内弹窗等显著方式再次征得你的同意。你可以在本页面随时查阅最新版本。
</p>
<h2>九、联系我们</h2>
<p>
如你对本政策或个人信息保护有任何疑问、意见或投诉,可通过应用内反馈渠道联系我们,或发送邮件至
<strong>【待填写联系邮箱】</strong>。我们将在十五个工作日内予以回复。
</p>
</body>
</html>
@@ -0,0 +1,183 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>有家相册服务协议</title>
<style>
:root {
color-scheme: light dark;
}
body {
margin: 0;
padding: 20px 16px 48px;
font-size: 15px;
line-height: 1.75;
font-family:
-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei",
sans-serif;
color: #1f2937;
background: #ffffff;
word-break: break-word;
}
h1 {
font-size: 20px;
margin: 0 0 4px;
color: #111827;
}
h2 {
font-size: 16px;
margin: 28px 0 8px;
color: #ff6b00;
}
p {
margin: 8px 0;
}
ul {
margin: 8px 0;
padding-left: 20px;
}
li {
margin: 4px 0;
}
.meta {
color: #6b7280;
font-size: 13px;
margin-bottom: 16px;
}
.tip {
background: #fff7ed;
border-left: 3px solid #ff6b00;
padding: 8px 12px;
border-radius: 4px;
margin: 12px 0;
font-size: 14px;
}
a {
color: #ff6b00;
}
@media (prefers-color-scheme: dark) {
body {
color: #e5e7eb;
background: #181818;
}
h1 {
color: #f3f4f6;
}
.meta {
color: #9ca3af;
}
.tip {
background: #2a1a0d;
}
}
</style>
</head>
<body>
<h1>有家相册服务协议</h1>
<p class="meta">更新日期:2026年10月20日 | 生效日期:2026年10月20日</p>
<p>
欢迎使用「有家相册」!本协议是你与有家相册开发团队(以下简称“我们”)之间就使用「有家相册」应用(以下简称“本应用”)所订立的协议。
</p>
<div class="tip">
请你务必审慎阅读、充分理解本协议各条款,特别是免除或限制责任的条款。你点击弹窗中的“同意并接受”按钮,或实际使用本应用,即视为你已阅读并同意本协议的全部内容。如你不同意本协议,请停止使用本应用。
</div>
<h2>一、协议的接受与变更</h2>
<p>
我们可能根据业务发展或法律法规的变化适时修订本协议,修订后的协议将在应用内公布。若你在协议修订后继续使用本应用,即视为你接受修订后的协议;若你不同意修订内容,请停止使用本应用。
</p>
<h2>二、账号注册与安全</h2>
<ul>
<li>
你需使用手机号注册账号,并妥善保管账号与密码。密码经加密处理,我们不掌握你的明文密码。
</li>
<li>
账号仅限你本人使用,不得出借、转让或出售。因你保管不善导致的账号风险及损失,由你自行承担。
</li>
<li>
如发现账号被盗用或存在安全隐患,请立即通过应用内渠道联系我们处理。
</li>
</ul>
<h2>三、服务内容</h2>
<p>
本应用为你提供家庭相册、家族族谱管理、文件共享、成员与权限管理等服务。部分功能(如身份认证)需要额外完成实名核验后方可使用。我们有权根据运营需要调整服务内容,并将以适当方式告知你。
</p>
<h2>四、用户行为规范</h2>
<p>
你在使用本应用时,应遵守法律法规及公序良俗,不得利用本应用制作、上传、传播以下内容:
</p>
<ul>
<li>违反国家法律法规的内容;</li>
<li>侵害他人肖像权、名誉权、隐私权、知识产权等合法权益的内容;</li>
<li>含有病毒、木马等恶意程序的内容;</li>
<li>其他违反法律法规或本协议约定的内容。</li>
</ul>
<p>
你上传包含其他家庭成员的照片、视频等内容的,应事先取得相关权利人的同意。因你违反上述规范引发的争议与责任,由你自行承担;给我们造成损失的,我们有权依法追偿。
</p>
<h2>五、内容与知识产权</h2>
<ul>
<li>
你上传的照片、视频、文件及录入的家庭信息,其权利仍归你或相关权利人所有。
</li>
<li>
为向你提供存储、展示、共享等本应用功能,你授予我们在服务范围内使用上述内容的必要许可(如存储、传输、在你的家族成员范围内按权限展示)。该许可仅限于提供服务之目的,不包含向无关第三方披露。
</li>
<li>
本应用的软件、界面、图标、商标等知识产权归我们或相关权利人所有,未经许可不得复制、修改或用于商业目的。
</li>
</ul>
<h2>六、隐私保护</h2>
<p>
我们重视你的个人信息保护,具体收集与使用规则请查阅《隐私政策》。该政策是本协议不可分割的组成部分。
</p>
<h2>七、服务的变更、中断与终止</h2>
<ul>
<li>
因系统维护、升级或不可抗力等原因,服务可能出现短暂中断,我们将尽力提前告知并缩短中断时间。
</li>
<li>
若你严重违反本协议或法律法规,我们有权视情节采取限制功能、暂停或终止服务的措施。
</li>
<li>
你可以随时停止使用本应用并申请注销账号。账号注销后,我们将依照《隐私政策》处理你的个人信息。
</li>
</ul>
<h2>八、免责声明与责任限制</h2>
<ul>
<li>
因不可抗力、网络运营商故障、第三方服务异常等非我们可控因素导致的服务中断或数据损失,我们将尽力协助处理,但在法律允许的范围内不承担责任。
</li>
<li>
请你自行对重要的照片、文件保留备份。对于因你误操作(如删除后清空回收站)造成的数据丢失,我们无法恢复。
</li>
<li>
我们不对家族成员自行录入的信息的准确性、完整性作保证,相关信息的真实性由录入者负责。
</li>
</ul>
<h2>九、法律适用与争议解决</h2>
<p>
本协议的订立、履行与解释均适用中华人民共和国法律。因本协议产生的争议,双方应友好协商解决;协商不成的,任何一方可向有管辖权的人民法院提起诉讼。
</p>
<h2>十、联系我们</h2>
<p>
如你对本协议有任何疑问、意见或建议,可通过应用内反馈渠道联系我们,或发送邮件至
<strong>【待填写联系邮箱】</strong>。
</p>
</body>
</html>
+8 -39
View File
@@ -7,6 +7,7 @@ import {
bearerAuth,
extractBusinessError,
} from "@/common/http.interceptor";
import { copyIntoAppUploadDir } from "@/utils/appUploadFile";
/**
* 上传队列 store:任务状态的唯一持有者,传输列表页只是它的视图。
@@ -80,47 +81,15 @@ function resolveChunkSize(): number {
return (intranet ? 3 : 1) * 1024 * 1024;
}
// #ifdef APP-PLUS
/**
* App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
* uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先把文件复制成
* _doc/uploads/uid-1-1.ext 再传;真机验证前不引入分片循环(读不了本地字节切片)。
* 项目 types 未含 plus 声明,本地宽松兜底(仅 App 运行时真实存在)。
*/
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
// #endif
function prepareAppFilePath(item: UploadItem): Promise<string> {
// #ifdef APP-PLUS
const chunkName = `uni-${Date.now()}-1-1${extOf(item.name)}`;
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
"_doc/",
(root: any) => {
root.getDirectory(
"uploads",
{ create: true },
(dir: any) => {
plus.io.resolveLocalFileSystemURL(
item.filePath,
(entry: any) => {
entry.copyTo(
dir,
chunkName,
(moved: any) => resolve(moved.toLocalURL()),
reject,
);
},
reject,
);
},
reject,
);
},
reject,
);
});
// App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
// uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先复制成协议名再传;
// 真机验证前不引入分片循环(读不了本地字节切片)
return copyIntoAppUploadDir(
item.filePath,
`uni-${Date.now()}-1-1${extOf(item.name)}`,
);
// #endif
// #ifndef APP-PLUS
return Promise.resolve(item.filePath);
+364
View File
@@ -0,0 +1,364 @@
/**
* App 端二进制落盘(下载保存 / 图片预览 / 音视频播放共用)
*
* 为什么需要:uni.getFileSystemManager 与 uni.chooseFile 一样仅小程序可用,
* App 端方法根本不存在,writeFile/writeFileSync 一调就抛 is not a function;
* 而 5+ 的 plus.io FileWriter.write 只接受字符串,写不了二进制字节,只能走 Native.js。
*
* 过桥策略:JS 侧按 512KB 切片转 base64,Android 交给 android.util.Base64.decode
* 得到 Java byte[] 再 FileOutputStream.write,iOS 交给 NSData +
* NSFileHandle.writeData —— 字节始终以 Java/ObjC 对象在原生侧流转,
* 不做逐字节的 plus.android.invoke,避免大文件把桥打爆。
*/
// #ifdef APP-PLUS
// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
declare const plus: any;
// #endif
/** 单次过桥字节数:512KB 转 base64 约 683KB 字符串,兼顾内存峰值与调用次数 */
const BYTES_PER_TRIP = 512 * 1024;
const BASE64_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/**
* 纯 JS base64(浏览器 btoa 在 App 逻辑层不保证存在,不依赖它):
* 只用来把一段字节变成原生侧能一次性解码的字符串。
*/
function base64OfChunk(chunk: Uint8Array): string {
let out = "";
const len = chunk.length;
for (let index = 0; index < len; index += 3) {
const b0 = chunk[index];
const b1 = index + 1 < len ? chunk[index + 1] : 0;
const b2 = index + 2 < len ? chunk[index + 2] : 0;
out += BASE64_CHARS[b0 >> 2];
out += BASE64_CHARS[((b0 & 3) << 4) | (b1 >> 4)];
out += index + 1 < len ? BASE64_CHARS[((b1 & 15) << 2) | (b2 >> 6)] : "=";
out += index + 2 < len ? BASE64_CHARS[b2 & 63] : "=";
}
return out;
}
/** 文件名里的路径分隔符与非法字符会让原生建不出文件,统一压平 */
function safeFileName(fileName: string): string {
const clean = fileName.replace(/[\\/:*?"<>|]/g, "_").trim();
return clean || `file-${Date.now()}`;
}
/** 公共下载目录下的子文件夹:文件管理器 →「下载」里一眼能看到 */
const PUBLIC_DOWNLOAD_FOLDER = "有家啦";
/** 常见扩展名的 MIME:MediaStore 靠它归类,未知按二进制流 */
function mimeOf(fileName: string): string {
const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
const map: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
mp3: "audio/mpeg",
m4a: "audio/mp4",
wav: "audio/wav",
aac: "audio/aac",
mp4: "video/mp4",
mov: "video/quicktime",
"3gp": "video/3gpp",
pdf: "application/pdf",
txt: "text/plain",
json: "application/json",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
zip: "application/zip",
};
return map[ext] || "application/octet-stream";
}
/** 下载文件名加时间戳:同名文件不互相覆盖,返回路径也可预测 */
function uniqueFileName(fileName: string): string {
const now = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
const dot = fileName.lastIndexOf(".");
if (dot <= 0) {
return `${fileName}-${stamp}`;
}
return `${fileName.slice(0, dot)}-${stamp}${fileName.slice(dot)}`;
}
// #ifdef APP-PLUS
/** _doc/files 目录(不存在就建):下载/预览产物与上传用的 _doc/uploads 分开放 */
function androidFilesDir(): string {
const root = plus.io.convertLocalFileSystemURL("_doc");
const dir = `${root}/files`;
const File = plus.android.importClass("java.io.File");
// File.mkdirs 是同步调用:建目录不必再绕 plus.io 的异步回调
plus.android.invoke(new File(dir), "mkdirs");
return dir;
}
/** 把整段字节按 512KB 分块写进任意 Java 输出流(本地文件 / MediaStore 都走它) */
function writeChunks(bytes: ArrayBuffer, output: any): void {
const Base64 = plus.android.importClass("android.util.Base64");
const total = bytes.byteLength;
for (let offset = 0; offset < total; offset += BYTES_PER_TRIP) {
const chunk = new Uint8Array(
bytes,
offset,
Math.min(BYTES_PER_TRIP, total - offset),
);
// importClass 出来的对象要直接调:plus.android.invoke 对本重载会静默失败,
// 文件落成 0 字节且不抛异常
const raw = Base64.decode(base64OfChunk(chunk), 0);
output.write(raw);
}
output.flush();
}
/** Android:base64 → byte[] → FileOutputStream,按 512KB 分块顺序写入 */
function writeAndroid(bytes: ArrayBuffer, destPath: string): void {
const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
const output = new FileOutputStream(destPath);
try {
writeChunks(bytes, output);
} finally {
output.close();
}
}
/** Android 版本号:29 = Android 10,MediaStore.Downloads 从这版才有 */
function androidSdkInt(): number {
try {
const Build = plus.android.importClass("android.os.Build$VERSION");
const sdk = Number(Build.SDK_INT);
if (sdk > 0) {
return sdk;
}
} catch {
// 反射失败时按系统版本号估算
}
const major = Number.parseInt(String(plus.os?.version || ""), 10);
if (Number.isNaN(major)) {
return 0;
}
return major >= 10 ? 29 : 22;
}
/** 公共「下载」目录(/storage/emulated/0/Download) */
function publicDownloadDir(): string {
const Environment = plus.android.importClass("android.os.Environment");
// 静态方法直接调:plus.android.invoke 传类对象不生效,会回 null
const dir = Environment.getExternalStoragePublicDirectory("Download");
if (!dir) {
throw new Error("public-download-dir-unavailable");
}
return String(plus.android.invoke(dir, "getAbsolutePath"));
}
/**
* Android 10+:写 MediaStore.Downloads,文件直接出现在「下载/有家啦」,
* 应用自建条目无需任何存储权限,用户在文件管理器里就能看到。
* 返回公共目录里的绝对路径(openDocument 用)。
*/
function writeAndroidPublic(bytes: ArrayBuffer, fileName: string): string {
const MediaStoreDownloads = plus.android.importClass(
"android.provider.MediaStore$Downloads",
);
const ContentValues = plus.android.importClass(
"android.content.ContentValues",
);
const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
const values = new ContentValues();
values.put("_display_name", fileName);
values.put("mime_type", mimeOf(fileName));
values.put("relative_path", `Download/${PUBLIC_DOWNLOAD_FOLDER}`);
// 写完前不让其它应用看到半截文件
values.put("is_pending", 1);
const resolver = plus.android.invoke(
plus.android.runtimeMainActivity(),
"getContentResolver",
);
const uri = plus.android.invoke(
resolver,
"insert",
MediaStoreDownloads.EXTERNAL_CONTENT_URI,
values,
);
if (!uri) {
throw new Error("mediastore-insert-failed");
}
const pfd = plus.android.invoke(resolver, "openFileDescriptor", uri, "w");
if (!pfd) {
throw new Error("mediastore-open-failed");
}
const output = new FileOutputStream(
plus.android.invoke(pfd, "getFileDescriptor"),
);
try {
writeChunks(bytes, output);
} catch (error) {
// 写失败:删掉刚插入的空条目,避免「下载」里留一个 0 字节文件
try {
plus.android.invoke(resolver, "delete", uri, null, null);
} catch {
// 删不掉也无所谓,交给上层回退
}
throw error;
} finally {
output.close();
plus.android.invoke(pfd, "close");
}
// 清掉 pending 标记,文件正式对外可见
const done = new ContentValues();
done.put("is_pending", 0);
plus.android.invoke(resolver, "update", uri, done, null, null);
return `${publicDownloadDir()}/${PUBLIC_DOWNLOAD_FOLDER}/${fileName}`;
}
/**
* Android 9 及以下:直接写公共「下载/有家啦」,需要 WRITE_EXTERNAL_STORAGE。
* 没授权时这里会抛异常,由 writeDownloadFile 回退私有目录。
*/
function writeAndroidLegacy(bytes: ArrayBuffer, fileName: string): string {
const dir = `${publicDownloadDir()}/${PUBLIC_DOWNLOAD_FOLDER}`;
const File = plus.android.importClass("java.io.File");
plus.android.invoke(new File(dir), "mkdirs");
const destPath = `${dir}/${fileName}`;
writeAndroid(bytes, destPath);
// 不通知扫描的话,系统「下载」视图(MediaStore 索引)里看不到新文件
try {
const MediaScannerConnection = plus.android.importClass(
"android.media.MediaScannerConnection",
);
MediaScannerConnection.scanFile(
plus.android.runtimeMainActivity(),
[destPath],
null,
null,
);
} catch {
// 扫描失败只是索引慢,文件本身已经写好了
}
return destPath;
}
/** iOS:_doc/files 目录(不存在就建) */
function iosFilesDir(): string {
const root = plus.io.convertLocalFileSystemURL("_doc");
const dir = `${root}/files`;
const manager = plus.ios.invoke(
plus.ios.importClass("NSFileManager"),
"defaultManager",
);
plus.ios.invoke(
manager,
"createDirectoryAtPath:withIntermediateDirectories:attributes:error:",
dir,
true,
null,
null,
);
return dir;
}
/** iOS:base64 → NSData → NSFileHandle 顺序追加 */
function writeIos(bytes: ArrayBuffer, destPath: string): void {
const NSData = plus.ios.importClass("NSData");
// 先落一个空文件:目标不存在时 fileHandleForWritingAtPath 回 nil,后续 writeData 全丢
plus.ios.invoke(
plus.ios.invoke(NSData, "dataWithBase64EncodedString:", ""),
"writeToFile:atomically:",
destPath,
false,
);
const handle = plus.ios.invoke(
plus.ios.importClass("NSFileHandle"),
"fileHandleForWritingAtPath:",
destPath,
);
const total = bytes.byteLength;
for (let offset = 0; offset < total; offset += BYTES_PER_TRIP) {
const chunk = new Uint8Array(
bytes,
offset,
Math.min(BYTES_PER_TRIP, total - offset),
);
const data = plus.ios.invoke(
NSData,
"dataWithBase64EncodedString:",
base64OfChunk(chunk),
);
plus.ios.invoke(handle, "writeData:", data);
}
plus.ios.invoke(handle, "synchronizeFile");
plus.ios.invoke(handle, "closeFile");
}
// #endif
/**
* 把整段字节写成 _doc/files/<fileName>,返回**绝对路径**:
* uni.openDocument / previewImage / video / audio 在 App 上都能直接吃这个路径。
* 非 App 端不会调用到这里,返回空串(TS 需要一条无条件返回)。
*/
export function writeAppFileFromBytes(
bytes: ArrayBuffer,
fileName: string,
): string {
// #ifdef APP-PLUS
const platform = String(plus.os?.name || "").toLowerCase();
const dir = platform === "android" ? androidFilesDir() : iosFilesDir();
const destPath = `${dir}/${safeFileName(fileName)}`;
if (platform === "android") {
writeAndroid(bytes, destPath);
} else {
writeIos(bytes, destPath);
}
return destPath;
// #endif
// #ifndef APP-PLUS
return "";
// #endif
}
/** 下载落盘结果:path 给 openDocument,isPublic 决定提示文案 */
export interface SavedFile {
path: string;
isPublic: boolean;
}
/**
* 下载落到用户能找到的地方:
* - Android 10+:写 MediaStore 公共「下载/有家啦」,免存储权限,文件管理器可见;
* - Android 9-:直接写同一个公共目录(需存储写权限);
* - 上面两条失败(无权限/厂商 ROM 限制)与 iOS:回退应用私有目录,
* 只能靠 openDocument 打开后「另存/分享」导出。
* 文件名自动加时间戳:重复下载不互相覆盖,返回路径也能直接用于打开。
*/
export function writeDownloadFile(
bytes: ArrayBuffer,
fileName: string,
): SavedFile {
// #ifdef APP-PLUS
const target = uniqueFileName(safeFileName(fileName));
if (String(plus.os?.name || "").toLowerCase() === "android") {
try {
const path =
androidSdkInt() >= 29
? writeAndroidPublic(bytes, target)
: writeAndroidLegacy(bytes, target);
return { path, isPublic: true };
} catch {
// 权限/MediaStore 异常:退回私有目录,至少文件不丢
}
}
return { path: writeAppFileFromBytes(bytes, target), isPublic: false };
// #endif
// #ifndef APP-PLUS
return { path: "", isPublic: false };
// #endif
}
@@ -0,0 +1,58 @@
/**
* App 端上传前落盘(头像 / 身份证 / 网盘文件共用)
*
* 后端 PackUploadFile / PackUploadDmsFile 对 multipart filename 做 Split('-') 取末两段
* 判定分片序号(协议名 uid-序号-总数.ext),而 uni.uploadFile 在 App 上只能拿 filePath 的
* basename 当 filename——chooseImage/选择器给的临时文件名没有三段结构,后端直接数组越界。
* 故先把文件按协议名复制进 _doc/uploads,再把新路径交给 uni.uploadFile:
* multipart filename 即 basename,天然满足协议;页面需要把物理名存库时(AvatarFilename 等)
* 传同一个 targetName 即可保持与后端落盘名一致。
*/
// #ifdef APP-PLUS
// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
// #endif
/**
* 把 App 本地文件复制进 _doc/uploads 并起目标物理名,回可上传的本地路径。
* 非 App 端不需要这一层,原样返回(调用方按 filePath 直传即可)。
*/
export function copyIntoAppUploadDir(
sourcePath: string,
targetName: string,
): Promise<string> {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
"_doc/",
(root: any) => {
root.getDirectory(
"uploads",
{ create: true },
(dir: any) => {
plus.io.resolveLocalFileSystemURL(
sourcePath,
(entry: any) => {
entry.copyTo(
dir,
targetName,
(copied: any) => resolve(copied.toLocalURL()),
reject,
);
},
reject,
);
},
reject,
);
},
reject,
);
});
// #endif
// #ifndef APP-PLUS
return Promise.resolve(sourcePath);
// #endif
}
+423 -7
View File
@@ -1,16 +1,20 @@
/**
* 选文件(H5 / App 共用入口)
* 选文件(H5 / 小程序 / App 共用入口)
*
* uni.chooseFile 两端语义一致:H5 底层是 <input type="file">(tempFilePath 为 blob: URL,
* tempFiles 是 File 对象),App 底层是原生选择器(tempFilePath 为本地路径)。
* 差异只体现在拿到的 filePath 形态上——uni.uploadFile 两端都能吃,故上层无需分流。
* 平台能力差(官方平台差异表):
* - H5 / 微信小程序(及鸿蒙 4.31+):uni.chooseFile 原生可用,直接转发;
* - App(Android/iOS):uni.chooseFile 并不存在,调用即报 is not a function,
* Android 走 Native.js 的 ACTION_GET_CONTENT 系统文件选择器、iOS 走
* UIDocumentPickerViewController,均免原生插件(官方推荐路线)。
* 系统选择器回给的是 content:// 或沙盒 URL,而 5+ 的 uni.uploadFile / plus.io 只吃文件路径,
* 故 App 侧选中后一律先落到 _doc/uploads、再交出 file:// 路径。
* 用户取消选择按「空结果」处理而不是 reject,调用方不必为取消写 catch。
*/
export interface PickedFile {
/** 原始文件名(上传时作为 multipart form 字段名,后端按它落 OrgFileName) */
name: string;
/** H5 为 blob: URL,App 为本地文件路径 */
/** H5 为 blob: URL,App/小程序为本地文件路径(file:// 或绝对路径) */
filePath: string;
/** 字节数,个别端拿不到时为 0 */
size: number;
@@ -18,18 +22,54 @@ export interface PickedFile {
raw?: unknown;
}
export interface ChooseFileOptions {
/** 仅收指定后缀(如 [".fm"]):H5/小程序交给系统过滤;App 选择器不支持后缀过滤,选中后按文件名挡掉 */
extension?: string[];
}
/** 从路径兜底推导文件名(部分端 tempFiles 缺 name) */
function nameFromPath(path: string, index: number): string {
const clean = path.split("?")[0];
const tail = clean.substring(clean.lastIndexOf("/") + 1);
return tail || `file-${index + 1}`;
if (!tail) {
return `file-${index + 1}`;
}
try {
return decodeURIComponent(tail);
} catch {
return tail;
}
}
export function chooseFiles(count = 10): Promise<PickedFile[]> {
/** 后缀(含点、小写):App 端按名过滤与落盘命名共用 */
function extNameOf(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return dot > 0 ? fileName.slice(dot).toLowerCase() : "";
}
export function chooseFiles(
count = 10,
options: ChooseFileOptions = {},
): Promise<PickedFile[]> {
// #ifdef APP-PLUS
// 鸿蒙 App 运行时(HBuilderX 4.31+)有 uni.chooseFile,传统 Android/iOS 没有,必须分流
if (typeof (uni as any).chooseFile !== "function") {
return chooseByNativePicker(count, options);
}
// #endif
return chooseByUniApi(count, options);
}
/** uni.chooseFile 通道:H5 底层是 <input type="file">,小程序是 wx.chooseMessageFile */
function chooseByUniApi(
count: number,
options: ChooseFileOptions,
): Promise<PickedFile[]> {
return new Promise((resolve, reject) => {
uni.chooseFile({
count,
type: "all",
...(options.extension?.length ? { extension: options.extension } : {}),
success: (res) => {
// 声明为 string | string[](各端回传形态不一),统一收敛成数组
const raw = res.tempFilePaths as string | string[];
@@ -62,3 +102,379 @@ export function chooseFiles(count = 10): Promise<PickedFile[]> {
});
});
}
// #ifdef APP-PLUS
// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
/** onActivityResult 里只认我们自己这一发 */
const FILE_PICK_REQUEST = 31826;
/** App 端统一入口:按平台走系统文件选择器,再按后缀过滤(系统选择器不吃 extension) */
async function chooseByNativePicker(
count: number,
options: ChooseFileOptions,
): Promise<PickedFile[]> {
const platform = String(plus.os?.name || "").toLowerCase();
const picked =
platform === "android"
? await pickAndroidFiles(count)
: await pickIosFiles(count);
const extension = (options.extension || []).map((item) => item.toLowerCase());
const files = extension.length
? picked.filter((file) => extension.includes(extNameOf(file.name)))
: picked;
// 选中了文件但全被后缀挡掉:报错让调用方提示,而不是静默返回空(像用户没选)
if (!files.length && extension.length && picked.length) {
throw new Error(`unsupported-file-type:${extension.join("/")}`);
}
return files;
}
/** _doc/uploads 建好并回绝对路径:FileOutputStream 要求真实目录存在 */
function ensureUploadDir(): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
"_doc/",
(root: any) => {
root.getDirectory(
"uploads",
{ create: true },
(dir: any) =>
resolve(plus.io.convertLocalFileSystemURL(dir.fullPath)),
reject,
);
},
reject,
);
});
}
/** plus.io 取本地文件大小:拿不到按 0(上传进度仍由 UploadTask 回报) */
function statLocalFileSize(filePath: string): Promise<number> {
return new Promise((resolve) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.getMetadata(
(meta: any) => resolve(Number(meta.size) || 0),
() => resolve(0),
);
},
() => resolve(0),
);
});
}
/** Android:ACTION_GET_CONTENT 系统文件选择器(官方推荐的 Native.js 路线,免原生插件) */
function pickAndroidFiles(count: number): Promise<PickedFile[]> {
return new Promise((resolve, reject) => {
const Intent = plus.android.importClass("android.content.Intent");
const intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
if (count > 1) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
}
const main = plus.android.runtimeMainActivity();
let finished = false;
const finish = (settle: () => void): void => {
if (finished) {
return;
}
finished = true;
main.onActivityResult = null;
settle();
};
// 不要把 intent 套一层 Intent.createChooser:chooser 经 Native.js 回传后
// startActivityForResult 收不到结果,表现为点「+」毫无动静
main.startActivityForResult(intent, FILE_PICK_REQUEST);
// 钩子必须在 start 之后再挂:部分基座拉起新界面时会先回调一次空 onActivityResult,
// 提前挂会被它当作「取消」立即收口,用户真正选中后的结果再也进不来
main.onActivityResult = (
requestCode: number,
resultCode: number,
data: any,
) => {
if (requestCode !== FILE_PICK_REQUEST || finished) {
return;
}
if (resultCode !== -1 || !data) {
finish(() => resolve([])); // RESULT_OK = -1,其余(取消/异常)按空结果收敛
return;
}
importAndroidResult(data).then(
(files) => finish(() => resolve(files)),
(error) => finish(() => reject(error)),
);
};
});
}
/** 多选走 ClipData,单选走 getData;两路都空按取消处理 */
function collectAndroidUris(data: any): any[] {
const uris: any[] = [];
const clip = plus.android.invoke(data, "getClipData");
if (clip) {
const total = Number(plus.android.invoke(clip, "getItemCount")) || 0;
for (let index = 0; index < total; index += 1) {
const item = plus.android.invoke(clip, "getItemAt", index);
uris.push(plus.android.invoke(item, "getUri"));
}
}
if (!uris.length) {
const single = plus.android.invoke(data, "getData");
if (single) {
uris.push(single);
}
}
return uris;
}
/** 批量落盘:单个 provider 打不开(云端/加密盘常见)不拖垮整批 */
async function importAndroidResult(data: any): Promise<PickedFile[]> {
const uris = collectAndroidUris(data);
if (!uris.length) {
return [];
}
const resolver = plus.android.invoke(
plus.android.runtimeMainActivity(),
"getContentResolver",
);
const dirPath = await ensureUploadDir();
const files: PickedFile[] = [];
let lastError: unknown = null;
for (let index = 0; index < uris.length; index += 1) {
try {
files.push(await importAndroidUri(resolver, uris[index], dirPath, index));
} catch (error) {
lastError = error;
}
}
if (!files.length && lastError) {
throw lastError;
}
return files;
}
/** 单个 content:// 落盘:取显示名 → 拷进 _doc/uploads → 回 file:// 路径与大小 */
async function importAndroidUri(
resolver: any,
uri: any,
dirPath: string,
index: number,
): Promise<PickedFile> {
const displayName =
queryUriColumn(resolver, uri, "_display_name") || `file-${index + 1}`;
const destPath = `${dirPath}/pick-${Date.now()}-${index}${extNameOf(displayName)}`;
copyUriToLocalFile(resolver, uri, destPath);
const filePath = `file://${destPath}`;
return {
name: displayName,
filePath,
size: await statLocalFileSize(filePath),
};
}
/**
* content:// → 本地文件:ContentResolver 输入流 + FileChannel.transferFrom 直拷,
* 不在 JS 侧过字节(Java 层零拷贝,Native.js 也不需要 byte[] 循环)。
* resolver/input 都是 plus.android.invoke 反射取回的实例,身上没有挂 JS 方法代理,
* 直接调 resolver.openInputStream 会报 "is not a function",必须走 invoke;
* FileOutputStream 是 importClass 后 new 出来的,方法代理完整,才可直接调。
*/
function copyUriToLocalFile(resolver: any, uri: any, destPath: string): void {
const input = plus.android.invoke(resolver, "openInputStream", uri);
if (!input) {
throw new Error("file-provider-unreadable");
}
const FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
const Channels = plus.android.importClass("java.nio.channels.Channels");
// FileChannel 也要先 importClass:getChannel() 的返回值才会挂上方法代理
plus.android.importClass("java.nio.channels.FileChannel");
const output = new FileOutputStream(destPath);
try {
const channelIn = Channels.newChannel(input);
const channelOut = output.getChannel();
// count 用 int 上限而不是 1e15:后者转 long 时会溢出,
// transferFrom 写 0 字节且不抛异常,落盘成空文件(后端报 Image cannot be loaded)
channelOut.transferFrom(channelIn, 0, 2147483647);
channelOut.close();
} finally {
try {
output.close();
} catch {
// 已关或写失败:以主流程异常为准
}
try {
plus.android.invoke(input, "close");
} catch {
// 同上
}
}
}
/** 读 ContentResolver 单列(_display_name 等):provider 不支持该列时给空串 */
function queryUriColumn(resolver: any, uri: any, column: string): string {
let cursor: any = null;
try {
cursor = plus.android.invoke(
resolver,
"query",
uri,
[column],
null,
null,
null,
);
if (!cursor || !plus.android.invoke(cursor, "moveToFirst")) {
return "";
}
const index = Number(plus.android.invoke(cursor, "getColumnIndex", column));
if (index < 0) {
return "";
}
return String(plus.android.invoke(cursor, "getString", index) ?? "");
} catch {
return "";
} finally {
if (cursor) {
try {
plus.android.invoke(cursor, "close");
} catch {
// cursor 已关或 provider 异常:忽略
}
}
}
}
/** iOS:UIDocumentPickerViewController(inPlace=false:系统把文件拷进沙盒,回调 URL 可直接读) */
function pickIosFiles(count: number): Promise<PickedFile[]> {
return new Promise((resolve, reject) => {
let picker: any = null;
let delegate: any = null;
const cleanup = (): void => {
try {
plus.ios.deleteObject(picker);
} catch {
// 已释放
}
try {
plus.ios.deleteObject(delegate);
} catch {
// 已释放
}
};
const settle = (files: PickedFile[]): void => {
settleIosFiles(files).then((list) => {
cleanup();
resolve(list);
}, reject);
};
try {
picker = plus.ios.newObject("UIDocumentPickerViewController");
// public.data 覆盖全部文档类型(.fm 这类自定义后缀没有专属 UTI,只能放开后按名挡)
plus.ios.invoke(
picker,
"initWithDocumentTypes:inPlace:",
["public.data"],
false,
);
delegate = plus.ios.implements("NSObject", {
"documentPicker:didPickDocumentsAtURLs:": (_picker: any, urls: any) => {
const total = Number(plus.ios.invoke(urls, "count")) || 0;
const files: PickedFile[] = [];
for (let index = 0; index < total; index += 1) {
const url = plus.ios.invoke(urls, "objectAtIndex:", index);
const path = String(plus.ios.invoke(url, "path") ?? "");
if (path) {
files.push({
name: nameFromPath(path, index),
filePath: `file://${path}`,
size: 0,
});
}
}
settle(files);
},
// iOS 11 之前的老选择器走单数回调,留着兜底
"documentPicker:didPickDocumentAtURL:": (_picker: any, url: any) => {
const path = String(plus.ios.invoke(url, "path") ?? "");
settle(
path
? [
{
name: nameFromPath(path, 0),
filePath: `file://${path}`,
size: 0,
},
]
: [],
);
},
"documentPickerWasCancelled:": () => {
cleanup();
resolve([]);
},
});
picker.plusSetAttribute("delegate", delegate);
picker.plusSetAttribute("allowsMultipleSelection", count > 1);
const controller = iosTopViewController();
plus.ios.invoke(
controller,
"presentViewController:animated:completion:",
picker,
true,
null,
);
} catch (error) {
cleanup();
reject(error);
}
});
}
/** 找到当前最上层的 ViewController:优先 keyWindow,回退当前 Webview 的根控制器 */
function iosTopViewController(): any {
let controller: any = null;
try {
const application = plus.ios.invoke(
plus.ios.importClass("UIApplication"),
"sharedApplication",
);
controller = plus.ios.invoke(
plus.ios.invoke(application, "keyWindow"),
"rootViewController",
);
} catch {
controller = null;
}
if (!controller) {
const webview = plus.ios.currentWebview();
controller = plus.ios.invoke(
plus.ios.invoke(webview, "window"),
"rootViewController",
);
}
// 有弹层时挂到最上层,避免 present 被忽略
for (let depth = 0; controller && depth < 6; depth += 1) {
const presented = plus.ios.invoke(controller, "presentedViewController");
if (!presented) {
break;
}
controller = presented;
}
return controller;
}
/** 沙盒文件补真实大小(选择器回调只给路径) */
async function settleIosFiles(files: PickedFile[]): Promise<PickedFile[]> {
await Promise.all(
files.map(async (file) => {
file.size = await statLocalFileSize(file.filePath);
}),
);
return files;
}
// #endif
+20 -10
View File
@@ -5,11 +5,14 @@
* 分片号从 1 循环到总片数逐片 DownloadFileCombine,写进 (FileSize+1)*1024 的缓冲拼回整文件
* (FileSize 单位 KB,旧系统同款缓冲预分配)。
* 落盘/播放双出口:saveFileBytes 走拦截器 saveArrayBuffer;toPlayableUrl 给预览与播放页
* (H5 是 blob: URL,小程序/App 写本地用户目录返回文件路径)。
* (H5 是 blob: URL,小程序写本地用户目录,App 走 Native.js 二进制落盘)。
* 分片请求走 requestBuffer 而不是 dmsFileService.DownloadFileCombine:后者会把文件名
* 再当 body 发一份(后端参数校验因此回「无效数据。」),且拼 URL 时没做 encodeURIComponent。
*/
import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor";
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
import dmsFileService from "@/service/master/dmsFileService";
import { writeAppFileFromBytes } from "@/utils/appFileWrite";
/** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */
export interface FileBytes {
@@ -30,9 +33,16 @@ async function fetchCombineBytes(
// 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致
for (let index = 1; index <= total; index += 1) {
segments[segments.length - 2] = String(index);
const chunk = await dmsFileService.DownloadFileCombine(segments.join("-"));
merged.set(new Uint8Array(chunk as ArrayBuffer), offset);
offset += (chunk as ArrayBuffer).byteLength;
// 裸 arraybuffer 通道:文件名含中文/空格必须编码,且后端报错时回的是 JSON,
// requestBuffer 会按 content-type 探测转成错误文案,不会把错误页当文件字节
const chunk = await requestBuffer({
url: `/DmsFile/DownloadFileCombine?fileName=${encodeURIComponent(
segments.join("-"),
)}`,
method: "POST",
});
merged.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
// 缓冲按 (FileSize+1)KB 预分配可能比真实字节多几字节,按实际写入长度截断
return merged.slice(0, offset).buffer as ArrayBuffer;
@@ -73,24 +83,24 @@ export async function saveFileBytes(file: FileBytes): Promise<void> {
/**
* 生成可预览/可播放的地址:
* H5 返回 blob: URL(用完交给调用方 revoke,播放页 onUnload 处理);
* 小程序/App 写本地用户目录返回文件路径(video/audio/image 组件都吃)
* 小程序写本地用户目录返回文件路径;App 用 Native.js 落 _doc/files 后返回绝对路径
* (uni.getFileSystemManager 在 App 不存在,video/audio/previewImage 都吃绝对路径)
*/
export function toPlayableUrl(file: FileBytes): string {
// #ifdef H5
return URL.createObjectURL(new Blob([file.bytes]));
// #endif
// #ifndef H5
let userDataPath = "";
// #ifdef MP-WEIXIN
userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
// #endif
// #ifdef APP-PLUS
userDataPath = "_doc";
return writeAppFileFromBytes(file.bytes, file.name);
// #endif
// #ifndef APP-PLUS
const userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
const filePath = `${userDataPath}/${file.name}`;
uni
.getFileSystemManager()
.writeFileSync(filePath, file.bytes, undefined as any);
return filePath;
// #endif
// #endif
}
+120 -6
View File
@@ -61,6 +61,45 @@ function computeMac(
return CryptoJS.HmacSHA256(salt.clone().concat(iv).concat(ct), macKey);
}
/** 随机字节:crypto-js 的 WordArray.random 在 App 逻辑层不可用 */
/** (它去找 Node 原生 crypto,取不到就抛 Native crypto module could not be used…) */
function randomWordArray(byteLength: number): WordArray {
interface WebCrypto {
getRandomValues?: (array: Uint32Array) => Uint32Array;
}
const webCrypto = (globalThis as { crypto?: WebCrypto }).crypto;
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const words = new Uint32Array(byteLength / 4);
webCrypto.getRandomValues(words);
return CryptoJS.lib.WordArray.create(Array.from(words), byteLength);
}
// #ifdef APP-PLUS
if (String(plus.os.name).toLowerCase() === "android") {
try {
type SecureRandomCtor = new () => { nextInt: () => number };
const SecureRandom = plus.android.importClass(
"java.security.SecureRandom",
) as unknown as SecureRandomCtor;
const secure = new SecureRandom();
const words: number[] = [];
for (let i = 0; i < byteLength / 4; i += 1) {
// nextInt 返回 Java 有符号 int,>>> 0 转无符号位模式
words.push(secure.nextInt() >>> 0);
}
return CryptoJS.lib.WordArray.create(words, byteLength);
} catch {
// 落到下面的兰底
}
}
// #endif
// 兰底:没有系统安全随机源时用 Math.random(盐/IV 只需唯一,不承载明文机密)
const words: number[] = [];
for (let i = 0; i < byteLength / 4; i += 1) {
words.push((Math.random() * 0x100000000) >>> 0);
}
return CryptoJS.lib.WordArray.create(words, byteLength);
}
/**
* 明文 JSON 文本 → .fm 信封文本。
* 盐与 IV 每次随机:同一谱同一密码两次导出密文也不同,不泄露任何相等关系。
@@ -70,8 +109,8 @@ export function encryptEnvelope(
password: string,
factor: string,
): string {
const salt = CryptoJS.lib.WordArray.random(16);
const iv = CryptoJS.lib.WordArray.random(16);
const salt = randomWordArray(16);
const iv = randomWordArray(16);
const { encKey, macKey } = deriveKeys(password, factor, salt);
const encrypted = CryptoJS.AES.encrypt(
CryptoJS.enc.Utf8.parse(plainText),
@@ -142,12 +181,87 @@ export function decryptEnvelope(
return plainText;
}
/** UTF-8 字符串 → ArrayBuffer(.fm 落盘/上传字节;H5 与 App webview 均有 TextEncoder) */
/**
* UTF-8 字符串 → ArrayBuffer(.fm 落盘/上传字节)。
* 手写编解码:App 逻辑层没有 TextEncoder/TextDecoder(会报 is not defined),
* 且必须支持 4 字节码点,否则 emoji/生僻字人名会被截断。
*/
export function stringToBytes(text: string): ArrayBuffer {
return new TextEncoder().encode(text).buffer as ArrayBuffer;
const buffer = new Uint8Array(text.length * 4);
let offset = 0;
for (let i = 0; i < text.length; i += 1) {
let code = text.charCodeAt(i);
// 代理对:合回完整码点
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
const next = text.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
i += 1;
}
}
if (code < 0x80) {
buffer[offset] = code;
offset += 1;
} else if (code < 0x800) {
buffer[offset] = 0xc0 | (code >> 6);
buffer[offset + 1] = 0x80 | (code & 0x3f);
offset += 2;
} else if (code < 0x10000) {
buffer[offset] = 0xe0 | (code >> 12);
buffer[offset + 1] = 0x80 | ((code >> 6) & 0x3f);
buffer[offset + 2] = 0x80 | (code & 0x3f);
offset += 3;
} else {
buffer[offset] = 0xf0 | (code >> 18);
buffer[offset + 1] = 0x80 | ((code >> 12) & 0x3f);
buffer[offset + 2] = 0x80 | ((code >> 6) & 0x3f);
buffer[offset + 3] = 0x80 | (code & 0x3f);
offset += 4;
}
}
return buffer.buffer.slice(0, offset);
}
/** ArrayBuffer → UTF-8 字符串(读回 .fm 文本) */
/** ArrayBuffer → UTF-8 字符串(读回 .fm 文本;分块拼接避免大文件卡顿) */
export function bytesToString(bytes: ArrayBuffer): string {
return new TextDecoder().decode(bytes);
const view = new Uint8Array(bytes);
const parts: string[] = [];
let chunk = "";
for (let i = 0; i < view.length; ) {
const lead = view[i];
let code: number;
let size: number;
if (lead < 0x80) {
code = lead;
size = 1;
} else if ((lead & 0xe0) === 0xc0) {
code = lead & 0x1f;
size = 2;
} else if ((lead & 0xf0) === 0xe0) {
code = lead & 0x0f;
size = 3;
} else {
code = lead & 0x07;
size = 4;
}
for (let j = 1; j < size && i + j < view.length; j += 1) {
code = (code << 6) | (view[i + j] & 0x3f);
}
i += size;
if (code > 0xffff) {
const point = code - 0x10000;
chunk += String.fromCharCode(
0xd800 + (point >> 10),
0xdc00 + (point & 0x3ff),
);
} else {
chunk += String.fromCharCode(code);
}
if (chunk.length >= 8192) {
parts.push(chunk);
chunk = "";
}
}
parts.push(chunk);
return parts.join("");
}