Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf0b2e299 | ||
|
|
72e6cdbeea | ||
|
|
57f7803e9e | ||
|
|
c4322bb7d5 | ||
|
|
0efd724f02 | ||
|
|
18ec0c4dc2 | ||
|
|
8681af531d | ||
|
|
e35a76437b | ||
|
|
5465fb7b27 | ||
|
|
ac4d65808a | ||
|
|
d5495a31d9 | ||
|
|
ef713c692a |
@@ -33,6 +33,7 @@
|
||||
"timi-web": "link:E:/WebProject/timi-web",
|
||||
"timi-web-api": "link:E:/WebProject/timi-web-api",
|
||||
"ts-node": "^10.9.2",
|
||||
"vconsole": "^3.15.1",
|
||||
"vue": "^3.5.16",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-echarts": "^8.0.1",
|
||||
|
||||
Generated
+29
@@ -44,6 +44,9 @@ importers:
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@24.13.3)(typescript@5.8.3)
|
||||
vconsole:
|
||||
specifier: ^3.15.1
|
||||
version: 3.15.1
|
||||
vue:
|
||||
specifier: ^3.5.16
|
||||
version: 3.5.40(typescript@5.8.3)
|
||||
@@ -904,6 +907,13 @@ packages:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
copy-text-to-clipboard@3.2.2:
|
||||
resolution: {integrity: sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
core-js@3.50.0:
|
||||
resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1439,6 +1449,9 @@ packages:
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
mutation-observer@1.0.3:
|
||||
resolution: {integrity: sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -1871,6 +1884,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vconsole@3.15.1:
|
||||
resolution: {integrity: sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==}
|
||||
|
||||
vite@6.4.3:
|
||||
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -2763,6 +2779,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
copy-text-to-clipboard@3.2.2: {}
|
||||
|
||||
core-js@3.50.0: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
@@ -3333,6 +3353,8 @@ snapshots:
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
mutation-observer@1.0.3: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -3818,6 +3840,13 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vconsole@3.15.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
copy-text-to-clipboard: 3.2.2
|
||||
core-js: 3.50.0
|
||||
mutation-observer: 1.0.3
|
||||
|
||||
vite@6.4.3(@types/node@24.13.3)(less@4.8.0)(terser@5.49.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.use_as_origin"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.imyeyu.gao.customer",
|
||||
"sha256_cert_fingerprints": [
|
||||
"EF:65:BA:62:7D:E3:1C:D3:BD:C7:D9:1D:6A:69:35:81:56:87:DF:03:0D:E7:76:27:94:90:5E:CC:F4:45:02:69"
|
||||
"3E:9C:CC:8A:29:71:18:74:BB:56:AA:88:AD:62:E1:F3:AC:F2:70:07:C4:67:D9:32:B8:A2:4B:B7:E7:8E:06:34"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<route-page :background="background" :component="component" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
import RoutePage from "@/components/RoutePage.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "CachedRoutePage"
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
background: string;
|
||||
component: Component;
|
||||
}>();
|
||||
</script>
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div class="page-transition">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition :name="transitionName">
|
||||
<keep-alive :include="keepAlivePageNames">
|
||||
<component
|
||||
:is="isCachedPage ? CachedRoutePage : RoutePage"
|
||||
:key="route.path"
|
||||
:background="pageBackground"
|
||||
:component="Component"
|
||||
/>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import CachedRoutePage from "@/components/CachedRoutePage.vue";
|
||||
import RoutePage from "@/components/RoutePage.vue";
|
||||
import { useGlobalUIStore } from "@/store/globalUI";
|
||||
|
||||
defineOptions({
|
||||
name: "PageTransition"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
// KeepAlive 直接缓存的是单根路由边界,列表页面位于该边界子树中,仍会收到 activated / deactivated 生命周期。
|
||||
const keepAlivePageNames = ["CachedRoutePage"];
|
||||
const cachedPageRouteNameSet = new Set(["SalesOrderList", "ProductList", "ProductStockList"]);
|
||||
const isCachedPage = computed(() => cachedPageRouteNameSet.has(String(route.name)));
|
||||
|
||||
const transitionName = ref("push-left");
|
||||
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||
|
||||
// ---------- 路由深度计算 ----------
|
||||
|
||||
const pathCache = new Map<string, number>();
|
||||
|
||||
function calcDepth(sourceRoute: RouteLocationNormalized): number {
|
||||
const queryDepth = parseTransitionDepth(sourceRoute.query.transitionDepth);
|
||||
if (queryDepth !== undefined) {
|
||||
return queryDepth;
|
||||
}
|
||||
|
||||
if (!sourceRoute.meta.dynamicDepth) {
|
||||
return Number(sourceRoute.meta.depth ?? 0);
|
||||
}
|
||||
|
||||
const fullPath = sourceRoute.fullPath;
|
||||
const cachedDepth = pathCache.get(fullPath);
|
||||
if (cachedDepth !== undefined) {
|
||||
return cachedDepth;
|
||||
}
|
||||
|
||||
const baseDepth = Number(sourceRoute.meta.baseDepth ?? 0);
|
||||
const pathSegments = fullPath.split("?")[0].split("/").filter(pathSegment => pathSegment !== "");
|
||||
const depth = baseDepth + (pathSegments.length - 1);
|
||||
pathCache.set(fullPath, depth);
|
||||
return depth;
|
||||
}
|
||||
|
||||
function parseTransitionDepth(value: unknown): number | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return parseTransitionDepth(value[0]);
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const depth = Number(text);
|
||||
if (!Number.isInteger(depth) || depth < 0 || 20 < depth) {
|
||||
return undefined;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
const unregisterGuard = router.beforeEach((to, from) => {
|
||||
const toDepth = calcDepth(to);
|
||||
const fromDepth = calcDepth(from);
|
||||
|
||||
if (fromDepth < toDepth) {
|
||||
transitionName.value = "push-left";
|
||||
} else if (toDepth < fromDepth) {
|
||||
transitionName.value = "push-right";
|
||||
} else {
|
||||
transitionName.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unregisterGuard();
|
||||
pathCache.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@easing: cubic-bezier(.19, 1, .22, 1);
|
||||
@duration: 520ms;
|
||||
@brightness: .98;
|
||||
@shadow-intensity: .15;
|
||||
@page-offset: 25%;
|
||||
|
||||
.page-transition {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow-x: clip;
|
||||
z-index: 0;
|
||||
|
||||
// ---------- push-left / push-right 公共激活状态 ----------
|
||||
|
||||
:deep(.push-left-enter-active),
|
||||
:deep(.push-left-leave-active),
|
||||
:deep(.push-right-enter-active),
|
||||
:deep(.push-right-leave-active) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
transition: transform @duration @easing, opacity .2s linear;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
// ---------- push-left:下一页从右滑入,当前页缩退到左侧 ----------
|
||||
|
||||
:deep(.push-left-enter-from) {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
|
||||
:deep(.push-left-enter-to) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
:deep(.push-left-leave-active) {
|
||||
transition: all @duration @easing, filter .3s;
|
||||
}
|
||||
|
||||
:deep(.push-left-leave-to) {
|
||||
filter: brightness(@brightness);
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
// ---------- push-right:当前页从右滑出,上一页从左侧复位 ----------
|
||||
|
||||
:deep(.push-right-enter-active) {
|
||||
z-index: 0;
|
||||
transition: transform @duration @easing, opacity .3s;
|
||||
}
|
||||
|
||||
:deep(.push-right-enter-from) {
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
:deep(.push-right-enter-to) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
:deep(.push-right-leave-active) {
|
||||
z-index: 1;
|
||||
transition: transform @duration @easing, box-shadow .3s;
|
||||
}
|
||||
|
||||
:deep(.push-right-leave-from) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
:deep(.push-right-leave-to) {
|
||||
transform: translate3d(105%, 0, 0);
|
||||
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<component :is="component" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "RoutePage"
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
component: Component;
|
||||
background: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: clip;
|
||||
background: v-bind(background);
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -86,7 +87,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
required: false,
|
||||
showAdd: true,
|
||||
showRemove: true,
|
||||
showTypeLabel: true,
|
||||
showTypeLabel: false,
|
||||
showViewerActions: true
|
||||
});
|
||||
|
||||
@@ -142,6 +143,7 @@ async function handleFileChange(event: Event): Promise<void> {
|
||||
if (!fileList.length || !props.upload) {
|
||||
return;
|
||||
}
|
||||
saveCapturedFilesFromInput(input, fileList);
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
|
||||
@@ -203,12 +203,10 @@ function removeCurrentPhoto(): void {
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
margin-left: 1.5rem;
|
||||
flex-direction: column;
|
||||
|
||||
.button {
|
||||
text-indent: 1.5em;
|
||||
}
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { ObjectDirective } from "vue";
|
||||
|
||||
const ENTRANCE_DURATION = 400;
|
||||
const ACTIVE_CLASS = "entrance-fade-up-enter-active";
|
||||
const FROM_CLASS = "entrance-fade-up-enter-from";
|
||||
const TO_CLASS = "entrance-fade-up-enter-to";
|
||||
const STATE_KEY = Symbol("entrance-transition-state");
|
||||
|
||||
type EntranceTransitionValue = number | string | { delay?: number | string } | undefined;
|
||||
|
||||
interface EntranceTransitionState {
|
||||
frameId?: number;
|
||||
timerId?: number;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
type EntranceTransitionElement = HTMLElement & {
|
||||
[STATE_KEY]?: EntranceTransitionState;
|
||||
};
|
||||
|
||||
function getDelay(value: EntranceTransitionValue): number {
|
||||
const rawDelay = typeof value === "object" && value !== null ? value.delay : value;
|
||||
const delay = Number(rawDelay ?? 0);
|
||||
return Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
||||
}
|
||||
|
||||
function setDelay(element: EntranceTransitionElement, delay: number): void {
|
||||
element.style.setProperty("--entrance-delay", `${delay}ms`);
|
||||
}
|
||||
|
||||
function cleanup(element: EntranceTransitionElement): void {
|
||||
const state = element[STATE_KEY];
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.frameId !== undefined) {
|
||||
window.cancelAnimationFrame(state.frameId);
|
||||
}
|
||||
if (state.timerId !== undefined) {
|
||||
window.clearTimeout(state.timerId);
|
||||
}
|
||||
|
||||
element.classList.remove(ACTIVE_CLASS, FROM_CLASS, TO_CLASS);
|
||||
element.style.removeProperty("--entrance-delay");
|
||||
delete element[STATE_KEY];
|
||||
}
|
||||
|
||||
const vEntranceTransition: ObjectDirective<HTMLElement, EntranceTransitionValue> = {
|
||||
beforeMount(element, binding) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = element as EntranceTransitionElement;
|
||||
const delay = getDelay(binding.value);
|
||||
target[STATE_KEY] = { delay };
|
||||
setDelay(target, delay);
|
||||
target.classList.add(ACTIVE_CLASS, FROM_CLASS);
|
||||
},
|
||||
|
||||
mounted(element) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = element as EntranceTransitionElement;
|
||||
const state = target[STATE_KEY];
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.frameId = window.requestAnimationFrame(() => {
|
||||
state.frameId = window.requestAnimationFrame(() => {
|
||||
state.frameId = undefined;
|
||||
target.classList.remove(FROM_CLASS);
|
||||
target.classList.add(TO_CLASS);
|
||||
state.timerId = window.setTimeout(() => cleanup(target), ENTRANCE_DURATION + state.delay);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updated(element, binding) {
|
||||
if (!(element instanceof HTMLElement) || binding.value === binding.oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = element as EntranceTransitionElement;
|
||||
const state = target[STATE_KEY];
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.delay = getDelay(binding.value);
|
||||
setDelay(target, state.delay);
|
||||
},
|
||||
|
||||
beforeUnmount(element) {
|
||||
if (element instanceof HTMLElement) {
|
||||
cleanup(element as EntranceTransitionElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default vEntranceTransition;
|
||||
@@ -2,7 +2,12 @@
|
||||
<div class="main-layout">
|
||||
<app-nav-bar @height-change="onChangeNavBarHeight" />
|
||||
<div class="router-view">
|
||||
<page-transition />
|
||||
<page-transition
|
||||
:keep-alive="shouldKeepAlive"
|
||||
:page-background="globalUIStore.bodyBackground"
|
||||
page-class="page"
|
||||
:respect-reduced-motion="false"
|
||||
/>
|
||||
</div>
|
||||
<t-tab-bar
|
||||
v-model="tabVal"
|
||||
@@ -33,16 +38,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppNavBar from "@/components/navbar/index.vue";
|
||||
import PageTransition from "@/components/PageTransition.vue";
|
||||
import { useGlobalUIStore } from "@/store/globalUI";
|
||||
import { useTabBarStore } from "@/store/tabBarStore";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { PageTransition } from "timi-tdesign-mobile";
|
||||
import { useUserStore } from "timi-web-api/store";
|
||||
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const cachedRouteNames = new Set(["SalesOrderList", "ProductList", "ProductStockList"]);
|
||||
|
||||
function shouldKeepAlive(sourceRoute: RouteLocationNormalizedLoaded): boolean {
|
||||
return cachedRouteNames.has(String(sourceRoute.name));
|
||||
}
|
||||
|
||||
// ---------- 导航边距 ----------
|
||||
|
||||
const navBarHeight = ref("0px");
|
||||
|
||||
+5
-2
@@ -9,7 +9,8 @@ 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 vEntranceTransition from "@/directives/entranceTransition";
|
||||
import { initializeTwaBridge } from "@/utils/twaBridge";
|
||||
import { VEntranceTransition } from "timi-tdesign-mobile";
|
||||
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();
|
||||
@@ -64,7 +67,7 @@ async function bootstrap(): Promise<void> {
|
||||
app.use(pinia);
|
||||
app.directive("can", VCan);
|
||||
app.directive("role", VRole);
|
||||
app.directive("entrance-transition", vEntranceTransition);
|
||||
app.directive("entrance-transition", VEntranceTransition);
|
||||
|
||||
const userStore = useUserStore(pinia);
|
||||
const currentStoreStore = useCurrentStoreStore(pinia);
|
||||
|
||||
+17
-12
@@ -1,3 +1,14 @@
|
||||
<template>
|
||||
<div v-if="canUseAi" class="ai-chat-page">
|
||||
<ai-chat-panel
|
||||
:store-name="currentStoreStore.currentStoreName"
|
||||
:session-key="sessionKey"
|
||||
:store-id="aiStoreId"
|
||||
@new-conversation="startNewConversation"
|
||||
/>
|
||||
</div>
|
||||
<t-empty v-else description="当前账号无权使用门店智能助手" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api/store";
|
||||
import AiChatPanel from "@/pages/ai/components/AiChatPanel.vue";
|
||||
@@ -10,22 +21,16 @@ defineOptions({
|
||||
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const sessionKey = computed(() => `${userStore.loginUser.user?.id || userStore.loginUser.id || ""}:${currentStoreStore.currentStoreId}`);
|
||||
const conversationResetToken = ref(0);
|
||||
const sessionKey = computed(() => `${userStore.loginUser.user?.id || userStore.loginUser.id || ""}:${currentStoreStore.currentStoreId}:${conversationResetToken.value}`);
|
||||
const canUseAi = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager));
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
const aiStoreId = computed(() => canUseAi.value && isGlobalManager.value ? currentStoreStore.currentStoreId : undefined);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="canUseAi" class="ai-chat-page">
|
||||
<ai-chat-panel
|
||||
:store-name="currentStoreStore.currentStoreName"
|
||||
:session-key="sessionKey"
|
||||
:store-id="aiStoreId"
|
||||
/>
|
||||
</div>
|
||||
<t-empty v-else description="当前账号无权使用门店智能助手" />
|
||||
</template>
|
||||
function startNewConversation(): void {
|
||||
conversationResetToken.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ai-chat-page {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
@send="submitMessage"
|
||||
@stop="stopGeneration"
|
||||
@retry="retryLastMessage"
|
||||
@new-conversation="emit('new-conversation')"
|
||||
>
|
||||
<template #welcome>
|
||||
<div class="welcome">
|
||||
@@ -25,10 +26,9 @@
|
||||
<p v-entrance-transition="140">我会根据当前门店的实时业务数据回答经营问题。</p>
|
||||
<div class="quick-list" v-entrance-transition="210">
|
||||
<t-button
|
||||
class="quick-question bg-white"
|
||||
v-for="question in aiQuickQuestionList"
|
||||
:key="question"
|
||||
class="quick-question"
|
||||
variant="outline"
|
||||
size="small"
|
||||
@click="submitMessage(question)"
|
||||
>
|
||||
@@ -104,6 +104,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
"new-conversation": [];
|
||||
}>();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
type MessageListExpose = {
|
||||
@@ -339,9 +342,7 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
|
||||
<style scoped lang="less">
|
||||
.shared-panel {
|
||||
:deep(.ai-chat-panel) {
|
||||
padding-top: var(--app-nav-height);
|
||||
}
|
||||
|
||||
:deep(.welcome) {
|
||||
margin: auto 0;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<t-popup v-model="popupVisible" placement="right" destroy-on-close>
|
||||
<t-popup
|
||||
v-model="popupVisible"
|
||||
placement="right"
|
||||
destroy-on-close
|
||||
:z-index="popupZIndex"
|
||||
:overlay-props="{ zIndex: popupZIndex - 1 }"
|
||||
>
|
||||
<section class="ai-conversation-list" @scroll="handleScroll">
|
||||
<header class="header glass-white">
|
||||
<h3>历史会话</h3>
|
||||
@@ -98,6 +104,8 @@ const emit = defineEmits<{
|
||||
delete: [conversationId: string];
|
||||
}>();
|
||||
|
||||
const popupZIndex = 4000;
|
||||
const deleteDialogZIndex = 12000;
|
||||
const pageSize = 20;
|
||||
const popupVisible = computed({
|
||||
get() {
|
||||
@@ -203,6 +211,8 @@ async function confirmDelete(conversation: GaoAiConversation): Promise<void> {
|
||||
content: "确定删除这段历史会话吗?删除后无法恢复。",
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
zIndex: deleteDialogZIndex,
|
||||
overlayProps: { zIndex: deleteDialogZIndex },
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
@@ -288,6 +298,7 @@ watch(() => props.sessionKey, async () => {
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
|
||||
.item {
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
:show-add="false"
|
||||
:show-remove="false"
|
||||
:submit="submitPhotoItems"
|
||||
show-type-label
|
||||
title="照片列表"
|
||||
/>
|
||||
</card-container>
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
:show-viewer-actions="!isCustomerBound"
|
||||
:add-actions="photoAddActions"
|
||||
:loading="isPhotoUploading"
|
||||
show-type-label
|
||||
@add="openPhotoPicker('OTHER')"
|
||||
@action="handlePhotoAction"
|
||||
@capture-photo="capturePhoto"
|
||||
@@ -244,6 +245,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 +640,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);
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
height="min(80vh, 48rem)"
|
||||
left-button-text="关闭"
|
||||
right-button-text=""
|
||||
:z-index="skuDetailPopupZIndex"
|
||||
:overlay-props="{ zIndex: skuDetailPopupZIndex - 1 }"
|
||||
@left-button-click="closeSkuDetail"
|
||||
>
|
||||
<div v-if="selectedSku" class="sku-popup">
|
||||
@@ -156,6 +158,7 @@ const product = ref<Product>();
|
||||
const isLoading = ref(false);
|
||||
const selectedSku = ref<ProductSku>();
|
||||
const skuDetailVisible = ref(false);
|
||||
const skuDetailPopupZIndex = 1000;
|
||||
const attachmentItemList = computed<PhotoItem[]>(() => (product.value?.attachmentList || [])
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
type="search"
|
||||
borderless
|
||||
clearable
|
||||
placeholder="搜索产品名称或编码"
|
||||
placeholder="搜索产品名称(拼音)或编码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +161,7 @@ async function fetch(reset = false, preserveScroll = false): Promise<void> {
|
||||
const result = await ProductAPI.stockProductList({
|
||||
index,
|
||||
size: pageSize,
|
||||
keyword: keyword.value || undefined
|
||||
keyword: keyword.value.trim() || undefined
|
||||
});
|
||||
if (token !== requestToken) {
|
||||
return;
|
||||
@@ -182,7 +182,7 @@ async function fetch(reset = false, preserveScroll = false): Promise<void> {
|
||||
const result = await ProductAPI.stockProductList({
|
||||
index: pageIndex.value,
|
||||
size: pageSize,
|
||||
keyword: keyword.value || undefined
|
||||
keyword: keyword.value.trim() || undefined
|
||||
});
|
||||
if (token !== requestToken) {
|
||||
return;
|
||||
@@ -256,6 +256,14 @@ async function toSkuList(item: ProductStockProduct): Promise<void> {
|
||||
if (!item.productId) {
|
||||
return;
|
||||
}
|
||||
if (item.skuCount === 1 && item.skuId) {
|
||||
await router.push({
|
||||
name: "ProductStockDetail",
|
||||
params: { skuId: item.skuId },
|
||||
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
|
||||
});
|
||||
return;
|
||||
}
|
||||
await router.push({
|
||||
name: "ProductStockSkuList",
|
||||
params: { productId: item.productId },
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div v-can="permissionCodes.productStock.read" class="product-stock-record-list">
|
||||
<div class="filters bg-white">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
v-model="keyword"
|
||||
class="keyword"
|
||||
type="search"
|
||||
borderless
|
||||
clearable
|
||||
placeholder="搜索产品名称或编码"
|
||||
placeholder="搜索产品名称(拼音)或编码"
|
||||
/>
|
||||
<t-dropdown-menu class="menus">
|
||||
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperationType" />
|
||||
|
||||
@@ -154,10 +154,6 @@ function getSelectedUnit(item: ProductStock): ProductSkuUnit | undefined {
|
||||
return unitList.find(unit => unit.id === selectedUnitId) || unitList.find(unit => unit.isBase) || unitList[0];
|
||||
}
|
||||
|
||||
function getSelectedUnitName(item: ProductStock): string {
|
||||
return getSelectedUnit(item)?.name || "基础单位";
|
||||
}
|
||||
|
||||
function getStockQuantityText(item: ProductStock, value?: number | string): string {
|
||||
const quantity = convertBaseQuantityToPackageQuantity(value ?? "0", getSelectedUnit(item)?.baseQuantityPerUnit ?? "1");
|
||||
return getProductQuantityText(quantity);
|
||||
|
||||
@@ -69,7 +69,7 @@ import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import type { ProductSkuUnit, ProductType } from "@/types/Product";
|
||||
import type { ProductSkuForm, ProductSkuUnitForm, ProductSpecForm } from "@/types/ProductEditor";
|
||||
import { Message } from "@/utils/message";
|
||||
import { formatProductAmount, getProductSkuSpecEntries } from "@/utils/product";
|
||||
import { formatProductAmount } from "@/utils/product";
|
||||
|
||||
defineOptions({ name: "ProductSkuUnitEditor" });
|
||||
|
||||
@@ -91,7 +91,6 @@ const visible = computed({
|
||||
set: value => handleVisibleChange(value)
|
||||
});
|
||||
const unitEditorZIndex = 12100;
|
||||
const skuSpecEntries = computed(() => sku.value ? getProductSkuSpecEntries(sku.value, props.specList) : []);
|
||||
|
||||
function handleVisibleChange(value: boolean): void {
|
||||
emit("update:modelValue", value);
|
||||
|
||||
@@ -16,13 +16,21 @@
|
||||
</t-cell-group>
|
||||
<t-form v-if="record" class="form" v-entrance-transition="70">
|
||||
<t-form-item label="冲正原因" required-mark>
|
||||
<div class="reason">
|
||||
<t-textarea
|
||||
v-model="reason"
|
||||
borderless
|
||||
autosize
|
||||
maxlength="500"
|
||||
placeholder="请输入冲正原因"
|
||||
placeholder="请输入或选择冲正原因"
|
||||
/>
|
||||
<t-button
|
||||
size="extra-small"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
@click="openReasonPicker"
|
||||
>选择</t-button>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item
|
||||
label="冲正时间"
|
||||
@@ -68,6 +76,16 @@
|
||||
</div>
|
||||
</t-form>
|
||||
<t-empty v-if="!record" :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
|
||||
<t-popup v-model="reasonPickerVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-if="reasonPickerVisible"
|
||||
v-model="reasonPickerDraft"
|
||||
:columns="reasonPickerColumns"
|
||||
title="选择冲正原因"
|
||||
@cancel="reasonPickerVisible = false"
|
||||
@confirm="confirmReason"
|
||||
/>
|
||||
</t-popup>
|
||||
<t-popup v-model="occurredAtPickerVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="occurredAtPickerVisible"
|
||||
@@ -111,12 +129,23 @@ const remark = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const occurredAtDraftText = ref("");
|
||||
const occurredAtPickerVisible = ref(false);
|
||||
const reasonPickerDraft = ref<string[]>([]);
|
||||
const reasonPickerVisible = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const requestNo = ref("");
|
||||
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
|
||||
const canReadSalesOrder = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.read));
|
||||
const reasonOptions = [
|
||||
"客户申请撤销本次资金操作",
|
||||
"收款金额填写有误",
|
||||
"退款金额填写有误",
|
||||
"资金操作重复",
|
||||
"资金操作有误",
|
||||
"订单信息有误"
|
||||
];
|
||||
const reasonPickerColumns = reasonOptions.map(item => ({ label: item, value: item }));
|
||||
|
||||
const minimumDateTimeText = "1970-01-01 00:00";
|
||||
|
||||
@@ -135,6 +164,17 @@ function resetOccurredAt(): void {
|
||||
occurredAtDraftText.value = "";
|
||||
}
|
||||
|
||||
function openReasonPicker(): void {
|
||||
const currentReason = reasonOptions.includes(reason.value.trim()) ? reason.value.trim() : reasonOptions[0];
|
||||
reasonPickerDraft.value = [currentReason];
|
||||
reasonPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmReason(value: Array<string | number>): void {
|
||||
reason.value = String(value[0] || reasonPickerDraft.value[0] || "");
|
||||
reasonPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function buildOccurredAt(): number {
|
||||
return Time.toTimestamp(parseDateTime(occurredAtText.value));
|
||||
}
|
||||
@@ -256,6 +296,18 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.reason {
|
||||
gap: .5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.t-textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.time-actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<section class="brand" v-entrance-transition>
|
||||
<logo />
|
||||
<logo @click="handleLogoClick" />
|
||||
<h1 v-text="appName" />
|
||||
<p v-text="appDescription" />
|
||||
</section>
|
||||
@@ -37,6 +37,7 @@ import { SettingAPI } from "timi-web-api";
|
||||
import Logo from "@/pages/user/component/Logo.vue";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { toggleVConsole } from "@/utils/vConsole";
|
||||
|
||||
type AgreementItem = {
|
||||
icon: string;
|
||||
@@ -55,6 +56,30 @@ const router = useRouter();
|
||||
const agreementItemList = ref<AgreementItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
let debugClickCount = 0;
|
||||
let debugClickTimer: number | undefined;
|
||||
|
||||
async function handleLogoClick(): Promise<void> {
|
||||
debugClickCount++;
|
||||
if (debugClickTimer !== undefined) {
|
||||
window.clearTimeout(debugClickTimer);
|
||||
}
|
||||
debugClickTimer = window.setTimeout(() => {
|
||||
debugClickCount = 0;
|
||||
debugClickTimer = undefined;
|
||||
}, 1_500);
|
||||
|
||||
if (debugClickCount < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugClickCount = 0;
|
||||
window.clearTimeout(debugClickTimer);
|
||||
debugClickTimer = undefined;
|
||||
|
||||
await toggleVConsole();
|
||||
}
|
||||
|
||||
async function openAgreement(item: AgreementItem): Promise<void> {
|
||||
await router.push({
|
||||
name: "Agreement",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -136,6 +136,7 @@ export type ProductStockProduct = {
|
||||
attachmentList?: Attachment[];
|
||||
productType?: ProductType;
|
||||
skuCount?: number;
|
||||
skuId?: string;
|
||||
onHandQuantity?: number | string;
|
||||
reservedQuantity?: number | string;
|
||||
availableQuantity?: number | string;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type VConsole from "vconsole";
|
||||
|
||||
let vConsoleInstance: VConsole | undefined;
|
||||
let isVConsoleLoading = false;
|
||||
|
||||
/**
|
||||
* 切换移动端调试控制台。
|
||||
*/
|
||||
export async function toggleVConsole(): Promise<void> {
|
||||
if (vConsoleInstance) {
|
||||
vConsoleInstance.destroy();
|
||||
vConsoleInstance = undefined;
|
||||
console.warn("[调试] vConsole 已关闭");
|
||||
return;
|
||||
}
|
||||
if (isVConsoleLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isVConsoleLoading = true;
|
||||
try {
|
||||
const { default: VConsole } = await import("vconsole");
|
||||
vConsoleInstance = new VConsole();
|
||||
console.warn("[调试] vConsole 已启用");
|
||||
} catch (error) {
|
||||
console.error("[调试] vConsole 启用失败", error);
|
||||
} finally {
|
||||
isVConsoleLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ pnpm --config.registry=https://registry.npmjs.org dlx @bubblewrap/cli@1.25.0 bui
|
||||
|
||||
当前 `assetlinks.json` 中登记的是本机调试证书指纹,只能配合本次验证 APK 使用。正式发布前必须替换为 release 证书指纹;如果使用 Google Play,还要把 Play App Signing 证书指纹一并登记。
|
||||
|
||||
## TWA 媒体桥接
|
||||
|
||||
TWA 通过 `postMessage` 接收网页直接拍摄的照片和视频,Android 10 及以上使用 `MediaStore` 分块写入系统相册,照片保存到“图片/糕店管理”,视频保存到“视频/糕店管理”。普通浏览器和从相册选择的文件不会触发该桥接。
|
||||
|
||||
桥接依赖 `assetlinks.json` 中的 `delegate_permission/common.use_as_origin` 关系。发布 APK 前必须使用实际 release 签名证书的 SHA-256 指纹更新该文件,否则来源验证失败,桥接不会启用。
|
||||
|
||||
## 前端发布顺序
|
||||
|
||||
1. 先上传新版本 `dist/assets`。
|
||||
|
||||
@@ -172,6 +172,10 @@
|
||||
android:resource="@xml/filepaths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.browser.customtabs.PostMessageService"
|
||||
android:exported="true" />
|
||||
|
||||
<service
|
||||
android:name=".DelegationService"
|
||||
android:enabled="@bool/enableNotification"
|
||||
|
||||
@@ -19,11 +19,39 @@ import android.content.pm.ActivityInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.browser.customtabs.CustomTabsCallback;
|
||||
import androidx.browser.customtabs.CustomTabsService;
|
||||
import androidx.browser.customtabs.CustomTabsSession;
|
||||
import com.google.androidbrowserhelper.trusted.QualityEnforcer;
|
||||
import com.google.androidbrowserhelper.trusted.TwaLauncher;
|
||||
|
||||
|
||||
|
||||
public class LauncherActivity
|
||||
extends com.google.androidbrowserhelper.trusted.LauncherActivity {
|
||||
private static final String TAG = "GaoTwaBridge";
|
||||
private static final Uri WEB_ORIGIN = Uri.parse("https://gao.imyeyu.com");
|
||||
private static final long CHANNEL_RETRY_DELAY_MILLIS = 500L;
|
||||
private static final int MAX_CHANNEL_ATTEMPTS = 20;
|
||||
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final CustomTabsCallback bridgeCallback = new BridgeCallback();
|
||||
private TwaMediaStoreBridge mediaStoreBridge;
|
||||
private CustomTabsSession postMessageSession;
|
||||
private boolean originValidationRequested;
|
||||
private boolean originValidated;
|
||||
private boolean postMessageChannelRequested;
|
||||
private boolean postMessageChannelReady;
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +59,8 @@ public class LauncherActivity
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
mediaStoreBridge = new TwaMediaStoreBridge(this);
|
||||
Log.i(TAG, "TWA Activity 已创建,开始初始化媒体相册桥接");
|
||||
// Setting an orientation crashes the app due to the transparent background on Android 8.0
|
||||
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
|
||||
// splash screen and Chrome will still respect the orientation.
|
||||
@@ -51,4 +81,242 @@ public class LauncherActivity
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CustomTabsCallback getCustomTabsCallback() {
|
||||
return bridgeCallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
Log.i(TAG, "TWA Activity 销毁,清理未完成的媒体传输");
|
||||
if (mediaStoreBridge != null) {
|
||||
mediaStoreBridge.abortAll();
|
||||
}
|
||||
mainHandler.removeCallbacksAndMessages(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void scheduleChannelRequest(int attempt) {
|
||||
if (attempt >= MAX_CHANNEL_ATTEMPTS || postMessageChannelReady) {
|
||||
return;
|
||||
}
|
||||
mainHandler.postDelayed(() -> requestPostMessageChannel(attempt), CHANNEL_RETRY_DELAY_MILLIS);
|
||||
}
|
||||
|
||||
private void requestPostMessageChannel(int attempt) {
|
||||
if (isFinishing() || postMessageChannelReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
CustomTabsSession session = getCustomTabsSession();
|
||||
if (session == null) {
|
||||
Log.w(TAG, "请求 PostMessage 通道失败:当前没有可用的 CustomTabsSession,attempt=" + attempt);
|
||||
scheduleChannelRequest(attempt + 1);
|
||||
return;
|
||||
}
|
||||
postMessageSession = session;
|
||||
|
||||
if (!originValidationRequested) {
|
||||
originValidationRequested = true;
|
||||
Log.i(TAG, "开始验证 TWA 来源关系:" + WEB_ORIGIN);
|
||||
session.validateRelationship(CustomTabsService.RELATION_USE_AS_ORIGIN, WEB_ORIGIN, null);
|
||||
return;
|
||||
}
|
||||
if (!originValidated || postMessageChannelRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
postMessageChannelRequested = true;
|
||||
boolean requested = session.requestPostMessageChannel(WEB_ORIGIN, WEB_ORIGIN, new Bundle());
|
||||
Log.i(TAG, "请求 PostMessage 通道,结果=" + requested);
|
||||
if (!requested) {
|
||||
postMessageChannelRequested = false;
|
||||
scheduleChannelRequest(attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 Bubblewrap 生成的 Android Browser Helper 版本没有公开 TWA 会话访问器,
|
||||
* 这里按字段类型读取会话,不依赖混淆后的私有字段名称。
|
||||
*/
|
||||
@Nullable
|
||||
private CustomTabsSession getCustomTabsSession() {
|
||||
try {
|
||||
Object twaLauncher = findFieldValue(this, TwaLauncher.class);
|
||||
if (twaLauncher == null) {
|
||||
Log.w(TAG, "未找到 TwaLauncher 实例");
|
||||
return null;
|
||||
}
|
||||
CustomTabsSession session = (CustomTabsSession) findFieldValue(twaLauncher, CustomTabsSession.class);
|
||||
if (session == null) {
|
||||
Log.w(TAG, "未找到 CustomTabsSession 实例");
|
||||
}
|
||||
return session;
|
||||
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||
Log.w(TAG, "读取 TWA 会话失败", exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Object findFieldValue(Object target, Class<?> fieldType) throws IllegalAccessException {
|
||||
Class<?> currentClass = target.getClass();
|
||||
while (currentClass != null) {
|
||||
for (Field field : currentClass.getDeclaredFields()) {
|
||||
if (!fieldType.isAssignableFrom(field.getType())) {
|
||||
continue;
|
||||
}
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
}
|
||||
currentClass = currentClass.getSuperclass();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void handleBridgeMessage(String message) {
|
||||
if (mediaStoreBridge == null || !postMessageChannelReady) {
|
||||
Log.w(TAG, "忽略媒体消息:桥接尚未就绪");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject payload = new JSONObject(message);
|
||||
String type = payload.optString("type");
|
||||
String id = payload.optString("id");
|
||||
Log.d(TAG, "收到网页桥接消息,type=" + type + ", id=" + id);
|
||||
if ("twa-bridge-ready-ack".equals(type)) {
|
||||
Log.i(TAG, "网页已确认 TWA 桥接就绪");
|
||||
return;
|
||||
}
|
||||
if (id.length() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("media-start".equals(type)) {
|
||||
String error = mediaStoreBridge.start(
|
||||
id,
|
||||
payload.optString("mimeType"),
|
||||
payload.optString("fileName"),
|
||||
payload.optLong("size", -1L)
|
||||
);
|
||||
Log.i(TAG, "开始保存媒体,id=" + id + ", mimeType=" + payload.optString("mimeType")
|
||||
+ ", fileName=" + payload.optString("fileName") + ", size=" + payload.optLong("size", -1L)
|
||||
+ ", result=" + (error == null ? "success" : error));
|
||||
postBridgeResponse(error == null ? "media-started" : "media-error", id, null, error);
|
||||
return;
|
||||
}
|
||||
if ("media-chunk".equals(type)) {
|
||||
int index = payload.optInt("index", -1);
|
||||
if (index < 0) {
|
||||
mediaStoreBridge.abort(id);
|
||||
postBridgeResponse("media-error", id, null, "媒体分块编号无效");
|
||||
return;
|
||||
}
|
||||
byte[] chunk = Base64.decode(payload.optString("data"), Base64.NO_WRAP);
|
||||
String error = mediaStoreBridge.append(id, index, chunk);
|
||||
if (error != null || index == 0 || index % 16 == 0) {
|
||||
Log.d(TAG, "写入媒体分块,id=" + id + ", index=" + index + ", bytes=" + chunk.length
|
||||
+ ", result=" + (error == null ? "success" : error));
|
||||
}
|
||||
postBridgeResponse(error == null ? "media-chunk-ack" : "media-error", id, index, error);
|
||||
return;
|
||||
}
|
||||
if ("media-finish".equals(type)) {
|
||||
String error = mediaStoreBridge.finish(id);
|
||||
Log.i(TAG, "完成保存媒体,id=" + id + ", result=" + (error == null ? "success" : error));
|
||||
postBridgeResponse(error == null ? "media-saved" : "media-error", id, null, error);
|
||||
return;
|
||||
}
|
||||
if ("media-abort".equals(type)) {
|
||||
Log.w(TAG, "网页主动中止媒体保存,id=" + id);
|
||||
mediaStoreBridge.abort(id);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
Log.e(TAG, "解析 TWA 媒体消息失败,原始消息长度=" + message.length(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void postBridgeResponse(String type, String id, @Nullable Integer index, @Nullable String error) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
response.put("type", type);
|
||||
response.put("id", id);
|
||||
if (index != null) {
|
||||
response.put("index", index);
|
||||
}
|
||||
if (error != null) {
|
||||
response.put("error", error);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
if (!postMessageChannelReady || postMessageSession == null) {
|
||||
return;
|
||||
}
|
||||
int result = postMessageSession.postMessage(response.toString(), null);
|
||||
Log.d(TAG, "发送桥接响应,type=" + type + ", id=" + id + ", result=" + result);
|
||||
});
|
||||
}
|
||||
|
||||
private final class BridgeCallback extends QualityEnforcer {
|
||||
@Override
|
||||
public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) {
|
||||
super.onNavigationEvent(navigationEvent, extras);
|
||||
Log.d(TAG, "TWA 导航事件=" + navigationEvent);
|
||||
if (navigationEvent == NAVIGATION_FINISHED) {
|
||||
scheduleChannelRequest(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelationshipValidationResult(
|
||||
int relation,
|
||||
@Nullable Uri requestedOrigin,
|
||||
boolean result,
|
||||
@Nullable Bundle extras
|
||||
) {
|
||||
super.onRelationshipValidationResult(relation, requestedOrigin, result, extras);
|
||||
if (relation != CustomTabsService.RELATION_USE_AS_ORIGIN || !WEB_ORIGIN.equals(requestedOrigin)) {
|
||||
return;
|
||||
}
|
||||
originValidated = result;
|
||||
Log.i(TAG, "TWA 来源验证结果=" + result + ", origin=" + requestedOrigin);
|
||||
if (result) {
|
||||
scheduleChannelRequest(0);
|
||||
} else {
|
||||
Log.w(TAG, "TWA 来源验证失败,请检查 assetlinks.json");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageChannelReady(@Nullable Bundle extras) {
|
||||
super.onMessageChannelReady(extras);
|
||||
postMessageChannelReady = true;
|
||||
Log.i(TAG, "PostMessage 通道已就绪");
|
||||
postMessageSession = getCustomTabsSession();
|
||||
if (postMessageSession == null) {
|
||||
Log.e(TAG, "PostMessage 通道已就绪,但无法获取 CustomTabsSession");
|
||||
return;
|
||||
}
|
||||
JSONObject readyMessage = new JSONObject();
|
||||
try {
|
||||
readyMessage.put("type", "twa-bridge-ready");
|
||||
readyMessage.put("version", 1);
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
int result = postMessageSession.postMessage(readyMessage.toString(), null);
|
||||
Log.i(TAG, "发送 TWA 桥接就绪消息,result=" + result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPostMessage(String message, @Nullable Bundle extras) {
|
||||
super.onPostMessage(message, extras);
|
||||
handleBridgeMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2026 imyeyu.com
|
||||
*
|
||||
* MIT License
|
||||
*/
|
||||
package com.imyeyu.gao.customer;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将 TWA 传来的媒体分块写入 Android 系统相册。
|
||||
*/
|
||||
final class TwaMediaStoreBridge {
|
||||
private static final String TAG = "GaoTwaMediaStore";
|
||||
private static final long MAX_MEDIA_SIZE = 512L * 1024L * 1024L;
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private final ContentResolver contentResolver;
|
||||
private final Map<String, PendingMedia> pendingMediaMap = new HashMap<>();
|
||||
|
||||
TwaMediaStoreBridge(Context context) {
|
||||
contentResolver = context.getContentResolver();
|
||||
}
|
||||
|
||||
synchronized String start(
|
||||
String id,
|
||||
String mimeType,
|
||||
String fileName,
|
||||
long expectedSize
|
||||
) {
|
||||
Log.i(TAG, "准备创建相册媒体,id=" + id + ", mimeType=" + mimeType + ", fileName=" + fileName
|
||||
+ ", expectedSize=" + expectedSize + ", sdk=" + Build.VERSION.SDK_INT);
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Log.w(TAG, "Android 版本低于 10,无法无权限写入相册");
|
||||
return "Android 10 以下系统不支持无权限写入系统相册";
|
||||
}
|
||||
if (id.length() == 0 || id.length() > 128) {
|
||||
return "媒体标识无效";
|
||||
}
|
||||
if (pendingMediaMap.containsKey(id)) {
|
||||
return "媒体传输已存在";
|
||||
}
|
||||
if (expectedSize <= 0 || expectedSize > MAX_MEDIA_SIZE) {
|
||||
return "媒体文件大小超出限制";
|
||||
}
|
||||
|
||||
String normalizedMimeType = normalizeMimeType(mimeType);
|
||||
if (normalizedMimeType == null) {
|
||||
return "只支持照片和视频";
|
||||
}
|
||||
|
||||
Uri mediaUri = null;
|
||||
try {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, buildDisplayName(fileName, normalizedMimeType));
|
||||
values.put(MediaStore.MediaColumns.MIME_TYPE, normalizedMimeType);
|
||||
values.put(MediaStore.MediaColumns.RELATIVE_PATH, buildRelativePath(normalizedMimeType));
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
|
||||
mediaUri = contentResolver.insert(getCollectionUri(normalizedMimeType), values);
|
||||
if (mediaUri == null) {
|
||||
Log.e(TAG, "MediaStore insert 返回空 Uri,id=" + id);
|
||||
return "创建相册媒体失败";
|
||||
}
|
||||
|
||||
OutputStream outputStream = contentResolver.openOutputStream(mediaUri);
|
||||
if (outputStream == null) {
|
||||
deleteQuietly(mediaUri);
|
||||
Log.e(TAG, "无法打开 MediaStore 输出流,id=" + id + ", uri=" + mediaUri);
|
||||
return "打开相册媒体失败";
|
||||
}
|
||||
pendingMediaMap.put(id, new PendingMedia(mediaUri, outputStream, expectedSize));
|
||||
Log.i(TAG, "相册媒体创建成功,id=" + id + ", uri=" + mediaUri + ", path="
|
||||
+ buildRelativePath(normalizedMimeType));
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
if (mediaUri != null) {
|
||||
deleteQuietly(mediaUri);
|
||||
}
|
||||
Log.e(TAG, "创建相册媒体异常,id=" + id, exception);
|
||||
return "创建相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized String append(String id, int index, byte[] chunk) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.get(id);
|
||||
if (pendingMedia == null) {
|
||||
return "媒体传输不存在";
|
||||
}
|
||||
if (index != pendingMedia.nextChunkIndex) {
|
||||
abort(id);
|
||||
return "媒体分块顺序错误";
|
||||
}
|
||||
if (chunk.length == 0 || pendingMedia.receivedSize + chunk.length > pendingMedia.expectedSize) {
|
||||
abort(id);
|
||||
return "媒体分块大小错误";
|
||||
}
|
||||
|
||||
try {
|
||||
pendingMedia.outputStream.write(chunk);
|
||||
pendingMedia.outputStream.flush();
|
||||
pendingMedia.receivedSize += chunk.length;
|
||||
pendingMedia.nextChunkIndex++;
|
||||
return null;
|
||||
} catch (IOException exception) {
|
||||
abort(id);
|
||||
Log.e(TAG, "写入相册媒体异常,id=" + id + ", index=" + index, exception);
|
||||
return "写入相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized String finish(String id) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.remove(id);
|
||||
if (pendingMedia == null) {
|
||||
return "媒体传输不存在";
|
||||
}
|
||||
if (pendingMedia.receivedSize != pendingMedia.expectedSize) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "媒体内容不完整,id=" + id + ", received=" + pendingMedia.receivedSize
|
||||
+ ", expected=" + pendingMedia.expectedSize);
|
||||
return "媒体内容不完整";
|
||||
}
|
||||
|
||||
try {
|
||||
pendingMedia.outputStream.flush();
|
||||
pendingMedia.outputStream.close();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
if (contentResolver.update(pendingMedia.mediaUri, values, null, null) != 1) {
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "发布相册媒体失败,MediaStore update 返回 0,id=" + id);
|
||||
return "发布相册媒体失败";
|
||||
}
|
||||
Log.i(TAG, "相册媒体发布成功,id=" + id + ", uri=" + pendingMedia.mediaUri
|
||||
+ ", bytes=" + pendingMedia.receivedSize);
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "保存相册媒体异常,id=" + id, exception);
|
||||
return "保存相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void abort(String id) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.remove(id);
|
||||
if (pendingMedia == null) {
|
||||
return;
|
||||
}
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.w(TAG, "已清理中止的相册媒体,id=" + id + ", uri=" + pendingMedia.mediaUri);
|
||||
}
|
||||
|
||||
synchronized void abortAll() {
|
||||
for (PendingMedia pendingMedia : pendingMediaMap.values()) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
}
|
||||
pendingMediaMap.clear();
|
||||
Log.i(TAG, "已清理全部未完成的相册媒体");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalizeMimeType(String mimeType) {
|
||||
String normalizedMimeType = mimeType == null ? "" : mimeType.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalizedMimeType.startsWith("image/")) {
|
||||
return normalizedMimeType.equals("image/*") ? "image/jpeg" : normalizedMimeType;
|
||||
}
|
||||
if (normalizedMimeType.startsWith("video/")) {
|
||||
return normalizedMimeType.equals("video/*") ? "video/mp4" : normalizedMimeType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Uri getCollectionUri(String mimeType) {
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
}
|
||||
return MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
}
|
||||
|
||||
private static String buildRelativePath(String mimeType) {
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return Environment.DIRECTORY_MOVIES + "/糕店管理";
|
||||
}
|
||||
return Environment.DIRECTORY_PICTURES + "/糕店管理";
|
||||
}
|
||||
|
||||
private static String buildDisplayName(String fileName, String mimeType) {
|
||||
String safeFileName = fileName == null ? "" : fileName
|
||||
.replaceAll("[^A-Za-z0-9._-]", "_")
|
||||
.replaceAll("^\\.+$", "");
|
||||
if (safeFileName.length() == 0) {
|
||||
safeFileName = "capture-" + System.currentTimeMillis();
|
||||
}
|
||||
if (safeFileName.lastIndexOf('.') < 0) {
|
||||
safeFileName += mimeType.startsWith("video/") ? ".mp4" : ".jpg";
|
||||
}
|
||||
return safeFileName;
|
||||
}
|
||||
|
||||
private void deleteQuietly(Uri uri) {
|
||||
try {
|
||||
contentResolver.delete(uri, null, null);
|
||||
} catch (Exception ignored) {
|
||||
// 相册清理失败时不能覆盖原始错误。
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(OutputStream outputStream) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (IOException ignored) {
|
||||
// 关闭失败时继续清理媒体记录。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingMedia {
|
||||
private final Uri mediaUri;
|
||||
private final OutputStream outputStream;
|
||||
private final long expectedSize;
|
||||
private long receivedSize;
|
||||
private int nextChunkIndex;
|
||||
|
||||
private PendingMedia(Uri mediaUri, OutputStream outputStream, long expectedSize) {
|
||||
this.mediaUri = mediaUri;
|
||||
this.outputStream = new BufferedOutputStream(outputStream, BUFFER_SIZE);
|
||||
this.expectedSize = expectedSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user