save image for TWA
This commit is contained in:
@@ -60,6 +60,7 @@ import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||
import { Message } from "@/utils/message";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
defineOptions({
|
||||
name: "EventRecordVerifyDialog"
|
||||
@@ -131,6 +132,7 @@ async function handlePhotoSelected(event: Event): Promise<void> {
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
|
||||
isPhotoUploading.value = true;
|
||||
try {
|
||||
|
||||
@@ -52,6 +52,7 @@ import type { MediaSelectorAddAction, MediaSelectorViewerItem } from "./types";
|
||||
import { getMediaPreviewURL, normalizePhotoItem } from "./utils";
|
||||
import type { PhotoItem } from "@/types/Photo.ts";
|
||||
import { Message } from "@/utils/message.ts";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -142,6 +143,7 @@ async function handleFileChange(event: Event): Promise<void> {
|
||||
if (!fileList.length || !props.upload) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import Network from "timi-web-api/utils/Network";
|
||||
import { VCan, VRole } from "timi-web-api/utils/directives/Access";
|
||||
import type { ApiResponse } from "timi-web-api";
|
||||
import { Message } from "@/utils/message";
|
||||
import { initializeTwaBridge } from "@/utils/twaBridge";
|
||||
import vEntranceTransition from "@/directives/entranceTransition";
|
||||
import timiWebStyle from "../../timi-web/src/assets/style/timi-web.less?inline";
|
||||
import tdesignCustomStyle from "../../timi-tdesign-mobile/src/assets/style/tdesign-custom.less?inline";
|
||||
@@ -24,6 +25,8 @@ export const pinia = createPinia();
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
const libraryOverrideStyleId = "library-override-style";
|
||||
|
||||
initializeTwaBridge();
|
||||
|
||||
function mountLibraryOverrideStyle(): void {
|
||||
const oldStyle = document.getElementById(libraryOverrideStyleId);
|
||||
oldStyle?.remove();
|
||||
|
||||
@@ -244,6 +244,7 @@ import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { normalizeBodyAreaValue } from "@/utils/customer";
|
||||
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
import { compressUploadImage } from "@/utils/imageCompression";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerEdit"
|
||||
@@ -638,6 +639,7 @@ async function handlePhotoSelected(event: Event): Promise<void> {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, [file]);
|
||||
isPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFile = await compressUploadImage(file);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AttachmentAPI, type Attachment } from "timi-web-api";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
export function useEventRecordPhoto() {
|
||||
const recordPhotoInputRef = ref<HTMLInputElement>();
|
||||
@@ -38,6 +39,7 @@ export function useEventRecordPhoto() {
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
isPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFileList = await compressUploadImageList(fileList);
|
||||
|
||||
@@ -183,6 +183,7 @@ import { resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
import { Storage, Time } from "timi-web";
|
||||
import { type Attachment, AttachmentAPI } from "timi-web-api";
|
||||
import EventSelector from "@/components/EventSelector.vue";
|
||||
@@ -437,6 +438,7 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
recordPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFileList = await compressUploadImageList(fileList);
|
||||
|
||||
@@ -226,6 +226,7 @@ import { emitListDataChanged, listDataEventKeys } from "@/utils/listDataEvents";
|
||||
import { formatProductAmount, getProductStatusText, getProductTypeText, normalizeProductQuantity, parseProductAmount } from "@/utils/product";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
type PickerValue = string | number;
|
||||
|
||||
@@ -647,6 +648,7 @@ async function handleAttachmentSelected(event: Event): Promise<void> {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, [file]);
|
||||
isAttachmentUploading.value = true;
|
||||
try {
|
||||
const tempFile = (await AttachmentAPI.upload(await compressUploadImage(file)))[0];
|
||||
|
||||
@@ -91,6 +91,7 @@ import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompr
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreDiaryEdit"
|
||||
@@ -180,6 +181,7 @@ async function handlePhotoSelected(event: Event): Promise<void> {
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
|
||||
isPhotoUploading.value = true;
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
v-if="canManageStoreDiary"
|
||||
class="action"
|
||||
title="门店日记"
|
||||
note="记录门店经营和护理过程"
|
||||
note="记录门店经营日常"
|
||||
arrow
|
||||
hover
|
||||
@click="goStoreDiaryManagement"
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
const TWA_ORIGIN = "https://gao.imyeyu.com";
|
||||
const TWA_BRIDGE_TIMEOUT = 10_000;
|
||||
const MEDIA_CHUNK_SIZE = 64 * 1024;
|
||||
const LOG_PREFIX = "[TWA 相册]";
|
||||
|
||||
type CapturedMediaType = "image" | "video";
|
||||
|
||||
interface TwaMessage {
|
||||
type: string;
|
||||
id?: string;
|
||||
index?: number;
|
||||
data?: string;
|
||||
error?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
type TwaResponseHandler = (message: TwaMessage) => void;
|
||||
|
||||
let bridgeInitialized = false;
|
||||
let bridgePort: MessagePort | undefined;
|
||||
let bridgeWaiterList: Array<(port: MessagePort | undefined) => void> = [];
|
||||
let transferQueue = Promise.resolve();
|
||||
const responseHandlerMap = new Map<string, TwaResponseHandler>();
|
||||
|
||||
/**
|
||||
* 初始化 TWA 通信监听。
|
||||
*
|
||||
* 普通浏览器不会收到带 MessagePort 的消息,因此初始化失败时不会影响网页功能。
|
||||
*/
|
||||
export function initializeTwaBridge(): void {
|
||||
if (bridgeInitialized || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
bridgeInitialized = true;
|
||||
window.addEventListener("message", handleWindowMessage);
|
||||
logInfo("已初始化网页侧通信监听");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将直接拍摄得到的媒体保存到 Android 系统相册。
|
||||
*
|
||||
* 非 TWA 环境会直接返回,不会阻塞原有上传流程。
|
||||
*/
|
||||
export function saveCapturedMediaToAlbum(file: File, mediaType: CapturedMediaType): void {
|
||||
if (!file.size) {
|
||||
logWarn("跳过相册保存:文件大小为 0", {
|
||||
fileName: file.name,
|
||||
mediaType
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
initializeTwaBridge();
|
||||
logInfo("收到相册保存请求", {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
fileSize: file.size,
|
||||
mediaType
|
||||
});
|
||||
transferQueue = transferQueue
|
||||
.then(async () => {
|
||||
await transferMedia(file, mediaType);
|
||||
})
|
||||
.catch(error => {
|
||||
// 相册保存是增强能力,失败时不能影响媒体上传。
|
||||
logError("相册保存失败,已忽略并继续原上传流程", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件输入框判断是否为直接拍摄,并启动相册保存。
|
||||
*/
|
||||
export function saveCapturedFilesFromInput(input: HTMLInputElement, fileList: readonly File[]): void {
|
||||
if (!input.capture || !fileList.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaType: CapturedMediaType = input.accept.toLowerCase().includes("video") ? "video" : "image";
|
||||
logInfo("识别为直接拍摄媒体", {
|
||||
accept: input.accept,
|
||||
capture: input.capture,
|
||||
fileCount: fileList.length,
|
||||
mediaType
|
||||
});
|
||||
for (const file of fileList) {
|
||||
saveCapturedMediaToAlbum(file, mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowMessage(event: MessageEvent): void {
|
||||
const message = parseMessage(event.data);
|
||||
if (message?.type !== "twa-bridge-ready") {
|
||||
return;
|
||||
}
|
||||
if (event.origin !== TWA_ORIGIN || !event.ports.length) {
|
||||
logWarn("忽略无效的 TWA 桥接就绪消息", {
|
||||
origin: event.origin,
|
||||
hasMessagePort: event.ports.length > 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo("收到 Android 侧桥接就绪消息", {
|
||||
origin: event.origin,
|
||||
version: message.version
|
||||
});
|
||||
bridgePort = event.ports[0];
|
||||
bridgePort.onmessage = handlePortMessage;
|
||||
bridgePort.start();
|
||||
bridgePort.postMessage(JSON.stringify({
|
||||
type: "twa-bridge-ready-ack",
|
||||
version: 1
|
||||
}));
|
||||
|
||||
const waiterList = bridgeWaiterList;
|
||||
bridgeWaiterList = [];
|
||||
for (const waiter of waiterList) {
|
||||
waiter(bridgePort);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortMessage(event: MessageEvent): void {
|
||||
const message = parseMessage(event.data);
|
||||
if (!message?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
responseHandlerMap.get(message.id)?.(message);
|
||||
}
|
||||
|
||||
async function transferMedia(file: File, mediaType: CapturedMediaType): Promise<void> {
|
||||
const port = await waitForBridgePort();
|
||||
if (!port) {
|
||||
logWarn("未连接到 TWA,跳过相册保存", {
|
||||
fileName: file.name,
|
||||
mediaType
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = createTransferId();
|
||||
let transferFinished = false;
|
||||
logInfo("开始传输媒体", {
|
||||
id,
|
||||
fileName: file.name,
|
||||
mediaType,
|
||||
fileSize: file.size,
|
||||
chunkSize: MEDIA_CHUNK_SIZE
|
||||
});
|
||||
try {
|
||||
await sendTransferMessage(port, id, {
|
||||
type: "media-start",
|
||||
id,
|
||||
mediaType,
|
||||
mimeType: file.type || `${mediaType}/*`,
|
||||
fileName: file.name,
|
||||
size: file.size
|
||||
}, "media-started");
|
||||
|
||||
let offset = 0;
|
||||
let index = 0;
|
||||
while (offset < file.size) {
|
||||
const chunk = await file.slice(offset, offset + MEDIA_CHUNK_SIZE).arrayBuffer();
|
||||
if (index === 0 || index % 16 === 0 || offset + chunk.byteLength >= file.size) {
|
||||
logDebug("发送媒体分块", {
|
||||
id,
|
||||
index,
|
||||
chunkSize: chunk.byteLength,
|
||||
offset
|
||||
});
|
||||
}
|
||||
await sendTransferMessage(port, id, {
|
||||
type: "media-chunk",
|
||||
id,
|
||||
index,
|
||||
data: encodeBase64(chunk)
|
||||
}, "media-chunk-ack", index);
|
||||
offset += chunk.byteLength;
|
||||
index++;
|
||||
}
|
||||
|
||||
await sendTransferMessage(port, id, {
|
||||
type: "media-finish",
|
||||
id
|
||||
}, "media-saved");
|
||||
transferFinished = true;
|
||||
logInfo("媒体已保存到系统相册", {
|
||||
id,
|
||||
fileName: file.name,
|
||||
fileSize: file.size
|
||||
});
|
||||
} finally {
|
||||
if (!transferFinished) {
|
||||
logWarn("媒体传输未完成,发送中止请求", { id });
|
||||
port.postMessage(JSON.stringify({
|
||||
type: "media-abort",
|
||||
id
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTransferMessage(
|
||||
port: MessagePort,
|
||||
id: string,
|
||||
message: Record<string, unknown>,
|
||||
expectedType: string,
|
||||
expectedIndex?: number
|
||||
): Promise<void> {
|
||||
const responsePromise = waitForTransferResponse(id, expectedType, expectedIndex);
|
||||
logDebug("发送桥接消息", {
|
||||
id,
|
||||
type: message.type,
|
||||
expectedType,
|
||||
expectedIndex
|
||||
});
|
||||
port.postMessage(JSON.stringify(message));
|
||||
await responsePromise;
|
||||
}
|
||||
|
||||
function waitForBridgePort(): Promise<MessagePort | undefined> {
|
||||
if (bridgePort) {
|
||||
return Promise.resolve(bridgePort);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const settle = (port?: MessagePort): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(port);
|
||||
};
|
||||
bridgeWaiterList.push(settle);
|
||||
logDebug("等待 Android 侧桥接就绪");
|
||||
window.setTimeout(() => {
|
||||
bridgeWaiterList = bridgeWaiterList.filter(waiter => waiter !== settle);
|
||||
logWarn("等待 TWA 桥接超时,当前页面不是已建立通道的 TWA,或 Android 来源校验未通过", {
|
||||
origin: window.location.origin,
|
||||
displayMode: getDisplayMode(),
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
settle();
|
||||
}, TWA_BRIDGE_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForTransferResponse(id: string, expectedType: string, expectedIndex?: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
responseHandlerMap.delete(id);
|
||||
logError("等待桥接响应超时", new Error(`${expectedType}, id=${id}`));
|
||||
reject(new Error("TWA 媒体保存响应超时"));
|
||||
}, 15_000);
|
||||
|
||||
responseHandlerMap.set(id, message => {
|
||||
if (message.type === "media-error") {
|
||||
window.clearTimeout(timeout);
|
||||
responseHandlerMap.delete(id);
|
||||
logError("Android 侧返回媒体错误", new Error(message.error || "TWA 媒体保存失败"));
|
||||
reject(new Error(message.error || "TWA 媒体保存失败"));
|
||||
return;
|
||||
}
|
||||
if (message.type !== expectedType || (expectedIndex !== undefined && message.index !== expectedIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(timeout);
|
||||
responseHandlerMap.delete(id);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function logInfo(message: string, detail?: unknown): void {
|
||||
console.warn(LOG_PREFIX, message, detail ?? "");
|
||||
}
|
||||
|
||||
function logDebug(message: string, detail?: unknown): void {
|
||||
console.warn(LOG_PREFIX, message, detail ?? "");
|
||||
}
|
||||
|
||||
function logWarn(message: string, detail?: unknown): void {
|
||||
console.warn(LOG_PREFIX, message, detail ?? "");
|
||||
}
|
||||
|
||||
function logError(message: string, error: unknown): void {
|
||||
console.error(LOG_PREFIX, message, error);
|
||||
}
|
||||
|
||||
function parseMessage(value: unknown): TwaMessage | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(value) as TwaMessage;
|
||||
return typeof message.type === "string" ? message : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function createTransferId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getDisplayMode(): string {
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
return "standalone";
|
||||
}
|
||||
if (window.matchMedia("(display-mode: fullscreen)").matches) {
|
||||
return "fullscreen";
|
||||
}
|
||||
return "browser";
|
||||
}
|
||||
Reference in New Issue
Block a user