ow
This commit is contained in:
1 parent
6417562c4f
commit
f1e6e116c3
31 files changed
+2065
-309
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
Reference in new issue
Block a user