url統一定義
This commit is contained in:
1 parent
69046e827c
commit
c6eb00b406
9 files changed
+209
-46
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/router/routes";
|
||||
import { writeDownloadFile } from "@/utils/appFileWrite";
|
||||
import { copyIntoAppUploadDir } from "@/utils/appUploadFile";
|
||||
import { normalizePayloadFileUrls } from "@/utils/fileUrl";
|
||||
|
||||
/**
|
||||
* 全项目唯一请求层(基于 uview-pro httpPlugin,main.ts 已注册)
|
||||
@@ -405,7 +406,9 @@ const httpInterceptor: RequestInterceptor = {
|
||||
throw markErrorNotified(new Error(bizMsg));
|
||||
|
||||
// 成功:剥壳返回 JSON body(service/* 经 utils/request 直接拿到后端实体对象)
|
||||
return rawData;
|
||||
// 响应层统一收口:把后端按请求 Host 拼错的绝对 FileServer 直链(如 UAT 反代未传 Host 时的
|
||||
// https://10.1.1.163/FileServer/...)改回可达地址,规则集中在 utils/fileUrl.ts,新展示点无需再逐个改写
|
||||
return normalizePayloadFileUrls(rawData);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import type { FamilyCard, FamilyTreeChartApi } from "./types";
|
||||
import { computed, inject } from "vue";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
import { FAMILY_TREE_CHART_API } from "./types";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -80,7 +81,8 @@ function onCardTap(): void {
|
||||
<view class="ftn__card" :style="{ borderTopColor: theme }" @tap="onCardTap">
|
||||
<view class="ftn__head">
|
||||
<view class="ftn__avatar" :style="{ borderColor: theme }">
|
||||
<image v-if="card.avatarUrl" class="ftn__avatar-img" :src="card.avatarUrl" mode="aspectFill" />
|
||||
<image v-if="card.avatarUrl" class="ftn__avatar-img" :src="absoluteFileUrl(card.avatarUrl)"
|
||||
mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="56" :color="theme" />
|
||||
</view>
|
||||
<view class="ftn__info">
|
||||
@@ -107,7 +109,8 @@ function onCardTap(): void {
|
||||
<view class="ftn__card" :style="{ borderTopColor: spouseTheme(sp) }">
|
||||
<view class="ftn__head">
|
||||
<view class="ftn__avatar" :style="{ borderColor: spouseTheme(sp) }">
|
||||
<image v-if="sp.avatarUrl" class="ftn__avatar-img" :src="sp.avatarUrl" mode="aspectFill" />
|
||||
<image v-if="sp.avatarUrl" class="ftn__avatar-img" :src="absoluteFileUrl(sp.avatarUrl)"
|
||||
mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="56" :color="spouseTheme(sp)" />
|
||||
</view>
|
||||
<view class="ftn__info">
|
||||
|
||||
@@ -38,6 +38,7 @@ import familyTreeService from "@/service/master/familyTreeService";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
import { chooseFiles } from "@/utils/chooseFiles";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
/** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */
|
||||
const emit = defineEmits<{ (e: "ready"): void }>();
|
||||
@@ -259,13 +260,17 @@ function canAmendPerson(personId: string): boolean {
|
||||
const spouseMaps = computed(() => data.value.CurrentPerson?.FamilyTreeSpouseMaps ?? []);
|
||||
const childMaps = computed(() => data.value.CurrentPerson?.FamilyTreeChildrenMaps ?? []);
|
||||
|
||||
/** 关系行头像查表:Spouse/Children 视图字段不带头像,回查全族人员档案拿 AvatarUrl(谱系图 getAvatar 同款口径) */
|
||||
/**
|
||||
* 关系行头像查表:Spouse/Children 视图字段不带头像,回查全族人员档案拿 AvatarUrl(谱系图 getAvatar 同款口径)。
|
||||
* 后端 AvatarUrl 按请求 Host 拼绝对地址,UAT 反代未传原始 Host 时会拼成内网 IP(如 https://10.1.1.163/...),
|
||||
* 一律过 absoluteFileUrl 归一(H5 锁回公网 origin,App 跟随当前线路)。
|
||||
*/
|
||||
function personAvatar(personId?: string): string {
|
||||
if (!personId) {
|
||||
return "";
|
||||
}
|
||||
const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[];
|
||||
return persons.find(p => p?.PersonId === personId)?.AvatarUrl ?? "";
|
||||
return absoluteFileUrl(persons.find(p => p?.PersonId === personId)?.AvatarUrl ?? "");
|
||||
}
|
||||
|
||||
/** 谱系图同款性别判定(family-tree-chart isMale 字面一致):空性别按男兜底 */
|
||||
@@ -1535,7 +1540,7 @@ defineExpose({
|
||||
<view class="home-card__glow" />
|
||||
<image class="home-card__mark" :src="brandGlyph" mode="aspectFit" />
|
||||
<view class="home-card__avatar" @tap="onUploadAvatar">
|
||||
<image v-if="storage.AvatarUrl" class="home-card__avatar-img" :src="storage.AvatarUrl"
|
||||
<image v-if="storage.AvatarUrl" class="home-card__avatar-img" :src="absoluteFileUrl(storage.AvatarUrl)"
|
||||
mode="aspectFill" />
|
||||
<text v-else class="home-card__avatar-ph">{{ displayName ? displayName.slice(0, 1) : "?" }}</text>
|
||||
<view class="home-card__avatar-cam">
|
||||
@@ -1717,8 +1722,8 @@ defineExpose({
|
||||
<view class="person-hero">
|
||||
<view class="person-hero__avatar"
|
||||
:style="avatarFallbackStyle(!!ancestorPerson.AvatarUrl, ancestorPerson.Gender)">
|
||||
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img" :src="ancestorPerson.AvatarUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img"
|
||||
:src="absoluteFileUrl(ancestorPerson.AvatarUrl)" mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="56" :color="genderTheme(ancestorPerson.Gender)" />
|
||||
</view>
|
||||
<view class="person-hero__main">
|
||||
@@ -1771,8 +1776,8 @@ defineExpose({
|
||||
<view class="person-hero">
|
||||
<view class="person-hero__avatar"
|
||||
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
|
||||
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img"
|
||||
:src="absoluteFileUrl(currentPerson.AvatarUrl)" mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
|
||||
</view>
|
||||
<view class="person-hero__main">
|
||||
@@ -2093,8 +2098,8 @@ defineExpose({
|
||||
<view class="person-hero">
|
||||
<view class="person-hero__avatar"
|
||||
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
|
||||
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img"
|
||||
:src="absoluteFileUrl(currentPerson.AvatarUrl)" mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
|
||||
</view>
|
||||
<view class="person-hero__main">
|
||||
@@ -2449,7 +2454,7 @@ defineExpose({
|
||||
<template #row="{ row }">
|
||||
<view class="pick-person">
|
||||
<view class="ft-av" :style="avatarFallbackStyle(!!row.AvatarUrl, row.Gender)">
|
||||
<image v-if="row.AvatarUrl" class="ft-av__img" :src="row.AvatarUrl" mode="aspectFill" />
|
||||
<image v-if="row.AvatarUrl" class="ft-av__img" :src="absoluteFileUrl(row.AvatarUrl)" mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="40" :color="genderTheme(row.Gender)" />
|
||||
</view>
|
||||
<view class="ft-mrow__main">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useThemeTokens } from "@/composables";
|
||||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||||
import { FamilyPersonData } from "@/data/auto/familyPersonData";
|
||||
import familyPersonService from "@/service/master/familyPersonService";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
/** 单个字段描述:type 缺省为文本;switch/date/textarea/gender 走各自控件 */
|
||||
interface FieldDef {
|
||||
@@ -445,7 +446,8 @@ async function onSave(): Promise<void> {
|
||||
<!-- hero(图示同款):左大圆头像点开选图,右侧名字实时拼接 + 谱名灰字 + 红字清除头像;新增未填名时回退用途标题 -->
|
||||
<view class="pe__hero">
|
||||
<view class="pe__avatar" @tap="onUploadAvatar">
|
||||
<image v-if="person.AvatarUrl" class="pe__avatar-img" :src="person.AvatarUrl" mode="aspectFill" />
|
||||
<image v-if="person.AvatarUrl" class="pe__avatar-img" :src="absoluteFileUrl(person.AvatarUrl)"
|
||||
mode="aspectFill" />
|
||||
<u-icon v-else name="account" size="52" color="#c4c8cf" />
|
||||
<view class="pe__avatar-mask">
|
||||
<text v-if="uploading" class="pe__avatar-mask-text">
|
||||
@@ -455,8 +457,10 @@ async function onSave(): Promise<void> {
|
||||
</view>
|
||||
</view>
|
||||
<view class="pe__hero-main">
|
||||
<text class="pe__hero-name">{{ heroName || props.title || t('FamilyPersonPage.detailTitle') }}</text>
|
||||
<text class="pe__hero-genealogy">{{ t("FamilyPersonPage.genealogyName") }} {{ person.GenealogyName || "-" }}</text>
|
||||
<text class="pe__hero-name">{{ heroName || props.title || t('FamilyPersonPage.detailTitle')
|
||||
}}</text>
|
||||
<text class="pe__hero-genealogy">{{ t("FamilyPersonPage.genealogyName") }} {{ person.GenealogyName
|
||||
|| "-" }}</text>
|
||||
<text v-if="person.AvatarUrl || person.AvatarFilename" class="pe__hero-clear" @tap="onClearAvatar">
|
||||
{{ t("FamilyPersonPage.clearAvatar") }}
|
||||
</text>
|
||||
@@ -500,9 +504,8 @@ async function onSave(): Promise<void> {
|
||||
</view>
|
||||
<u-switch v-else-if="field.type === 'switch'" :model-value="fv(field.prop) === 'Y'"
|
||||
:active-color="primary" @change="setField(field.prop, $event ? 'Y' : 'N')" />
|
||||
<textarea v-else-if="field.type === 'textarea'" class="pe__textarea"
|
||||
:value="fv(field.prop)" :maxlength="field.maxlength ?? 2000"
|
||||
:placeholder="t('FamilyPersonPage.tabIntro')"
|
||||
<textarea v-else-if="field.type === 'textarea'" class="pe__textarea" :value="fv(field.prop)"
|
||||
:maxlength="field.maxlength ?? 2000" :placeholder="t('FamilyPersonPage.tabIntro')"
|
||||
@input="setField(field.prop, $event.detail.value)" />
|
||||
<picker v-else-if="field.type === 'date'" mode="date" :value="datePart(fv(field.prop))"
|
||||
@change="setField(field.prop, $event.detail.value)">
|
||||
@@ -513,9 +516,8 @@ async function onSave(): Promise<void> {
|
||||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||||
</view>
|
||||
</picker>
|
||||
<input v-else class="pe__input" :value="fv(field.prop)"
|
||||
:maxlength="field.maxlength ?? 100" :placeholder="field.label"
|
||||
@input="setField(field.prop, $event.detail.value)">
|
||||
<input v-else class="pe__input" :value="fv(field.prop)" :maxlength="field.maxlength ?? 100"
|
||||
:placeholder="field.label" @input="setField(field.prop, $event.detail.value)">
|
||||
</view>
|
||||
</view>
|
||||
<!-- 基本区「更多资料」展开行:绿链 + 收起字段灰字提示,点开就地展开/收起 -->
|
||||
@@ -544,8 +546,8 @@ async function onSave(): Promise<void> {
|
||||
</text>
|
||||
</view>
|
||||
<view class="pe__idcard-thumb" @tap="onUploadIdCard">
|
||||
<image v-if="person.IdCardUrl" class="pe__idcard-thumb-img" :src="person.IdCardUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="person.IdCardUrl" class="pe__idcard-thumb-img"
|
||||
:src="absoluteFileUrl(person.IdCardUrl)" mode="aspectFill" />
|
||||
<view v-else class="pe__idcard-empty">
|
||||
<u-icon name="camera" size="40" color="#c4c8cf" />
|
||||
<text class="pe__idcard-empty-text">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 文件浏览页的数据契约:页面持有数据与状态,六个展示组件只吃 props。
|
||||
* 展示字段(sizeText / modifiedAt)由页面格式化成最终文案,组件不再二次加工。
|
||||
*/
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
/** 分类标签的取值:全部 / 图片 / 视频 / 文档 / 音频 */
|
||||
export type FileCategory = "all" | "image" | "video" | "document" | "audio";
|
||||
@@ -36,6 +37,9 @@ export interface FileItem {
|
||||
/**
|
||||
* 卡片缩略图取值:图片用 Url(Scale 压缩图)、视频用 FormattedPic(上传时抽帧的同名 .jpg),
|
||||
* 其余类型不给图。三处列表(网盘 / 共享域 / 回收站)统一走这里,不再各自写判断。
|
||||
* 后端 Url/FormattedPic 由 PackUrl 按请求 Host 拼绝对地址,UAT 反代未传原始 Host 时会拼成
|
||||
* 内网 IP(如 https://10.1.1.163/FileServer/Scale/...),这里统一过 absoluteFileUrl 归一
|
||||
* (H5 锁回公网 origin,App 跟随当前线路),三处列表一次覆盖。
|
||||
*/
|
||||
export function pickThumbUrl(
|
||||
kind: FileKind,
|
||||
@@ -43,10 +47,10 @@ export function pickThumbUrl(
|
||||
formattedPic?: string | null,
|
||||
): string | undefined {
|
||||
if (kind === "image") {
|
||||
return url || undefined;
|
||||
return url ? absoluteFileUrl(url) : undefined;
|
||||
}
|
||||
if (kind === "video") {
|
||||
return formattedPic || undefined;
|
||||
return formattedPic ? absoluteFileUrl(formattedPic) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 1. 一句话结论
|
||||
|
||||
后端下发的文件地址有**三种形态**,端上不能直接塞进 `<image src>` / 播放器,必须统一过 `absoluteFileUrl()`;加载失败还要给一次公网回落 `wanFileUrl()`。
|
||||
后端下发的文件地址有**三种形态**,端上不能直接塞进 `<image src>` / 播放器,必须统一过 `absoluteFileUrl()`;加载失败还要给一次公网回落 `wanFileUrl()`。归一规则已收敛为**单一入口** `utils/fileUrl.ts`,并由 `common/http.interceptor.ts` 在响应层调 `normalizePayloadFileUrls` 全局洗掉错主机绝对直链(详见第 4 节)。
|
||||
|
||||
## 2. 后端地址的三种形态
|
||||
|
||||
@@ -40,23 +40,39 @@
|
||||
|
||||
```
|
||||
stripLoopbackHost(url) 把 localhost / 127.0.0.1 / 0.0.0.0 剥成纯路径;非回环返回 null
|
||||
toPathAndQuery(url) 任意绝对地址剥成 path+query;相对路径原样返回(H5 用)
|
||||
withBase(base, path) 相对路径拼到 base;已绝对 / data: / blob: 原样返回(幂等)
|
||||
absoluteFileUrl(path) stripLoopbackHost 之后按 apiBaseUrl() 重拼 —— 当前线路地址
|
||||
wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebApiRoot —— 公网地址
|
||||
absoluteFileUrl(path) H5:剥成 path+query 按页面 origin 重拼;App:stripLoopbackHost 后按 apiBaseUrl() 重拼
|
||||
wanFileUrl(path) H5:原样返回(本就锁公网);App:把任意地址的 path+query 换到 WebSitePolicy.WebApiRoot
|
||||
normalizePayloadFileUrls(data) 响应层统一收口:递归改写返回体里「错主机的绝对 /FileServer/ 直链」到 fileUrlBase()(见下)
|
||||
```
|
||||
|
||||
`apiBaseUrl()` 来自访问线路(`api-endpoint-setting`):`lan` 模式返回用户在「我的 → 访问线路」填的内网地址,否则返回公网(App 开发构建取 `WebApiRootDevApp`,发布构建取 `WebApiRoot`)。
|
||||
**统一入口(单一文件收口)**:归一规则全部住在 `utils/fileUrl.ts` 一个文件;`common/http.interceptor.ts` 的成功分支只调用一次 `normalizePayloadFileUrls(rawData)`。它只改写同时满足「绝对 http(s) + 路径含 `/FileServer/` + 主机与基座不符」的字符串,因此:
|
||||
|
||||
- 会改:`https://10.1.1.163/FileServer/...`(UAT 反代未传 Host)→ 按基座(H5=origin / App=线路)重拼;
|
||||
- 不动:提交用的相对路径 `/FileServer/...` 与裸物理名(OrderKey/FileName,无主机)、`blob:`/`data:`/本地临时路径、主机本就正确的直链——避免污染保存回传;
|
||||
- 效果:H5 侧**新增展示点即使忘了调 `absoluteFileUrl`,数据已在响应层被洗干净**,不会再打到内网 IP。App 相对路径展示仍需 `absoluteFileUrl`(响应层故意不改相对路径,否则破坏提交键)。
|
||||
|
||||
**端间策略差异(固定口径)**:
|
||||
|
||||
- **H5(微信内置浏览器等)只能走公网域名(如 `https://uat.youjiala.com`),不得变更**:没有内网直连概念,文件地址一律剥成 path+query 后按当前页面 origin 重拼。这样即使后端按请求 Host 拼出了内网 IP(UAT 反代没传原始 Host 时会出 `https://10.1.1.163/FileServer/...`,外部浏览器 `ERR_CONNECTION_REFUSED`),也会被改回可达的公网地址;
|
||||
- **App 保留「访问线路」自定义外网/内网切换**:行为不变,资源地址跟随当前线路。
|
||||
|
||||
`apiBaseUrl()` 来自访问线路(`api-endpoint-setting`,仅 App 生效):`lan` 模式返回用户在「我的 → 访问线路」填的内网地址,否则返回公网(App 开发构建取 `WebApiRootDevApp`,发布构建取 `WebApiRoot`)。
|
||||
|
||||
实测映射(Node 侧验证过同一套逻辑):
|
||||
|
||||
| 输入 | `absoluteFileUrl`(内网 172.16.253.53:5235) | `wanFileUrl` |
|
||||
| 输入 | `absoluteFileUrl`(App 内网 172.16.253.53:5235) | `wanFileUrl` |
|
||||
| --- | --- | --- |
|
||||
| `http://localhost:5235/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` |
|
||||
| `/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` |
|
||||
| `http://172.16.253.53:5235/...?v=1` | 原样 | `https://uat.youjiala.com/...?v=1` |
|
||||
| `https://uat.youjiala.com/...` | 原样 | 原样(调用方判重后不再重试) |
|
||||
| `https://10.1.1.163/FileServer/...`(UAT 反代未传 Host) | 原样(内网 IP 非回环,App 在内网可达时本就应用它) | 换到 `https://uat.youjiala.com/FileServer/...` |
|
||||
| `""` / `undefined` | `""` | `""` |
|
||||
|
||||
H5 下 `absoluteFileUrl` 与输入主机无关,恒为「页面 origin + path+query」;`wanFileUrl` 原样返回。
|
||||
|
||||
## 5. 消费面:哪些地方必须过
|
||||
|
||||
| 位置 | 处理 |
|
||||
@@ -65,12 +81,12 @@ wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebA
|
||||
| `composables/useMediaShow.ts` `loadPhotos` 的 list 分支与相册明细分支 | `url` / `thumb` / `full` 全部过 `absoluteFileUrl` |
|
||||
| `composables/useMediaShow.ts` `loadTracks` / `loadVideos` | `url`、`pic`、`lrc`、`m3u8` 全部过 `absoluteFileUrl`(调试态下它们同样是 localhost 前缀) |
|
||||
|
||||
纪律:**凡是渲染后端文件地址的字段,一律先过 `absoluteFileUrl`;可交互/可预览的图片再挂一层 `@error` 回落 `wanFileUrl`。** 不要把 `FormattedPic` / `FileName` / `FormattedUrl` 直接绑到 `src`。
|
||||
纪律:**凡是渲染后端文件地址的字段,一律先过 `absoluteFileUrl`;可交互/可预览的图片再挂一层 `@error` 回落 `wanFileUrl`。** 不要把 `FormattedPic` / `FileName` / `FormattedUrl` 直接绑到 `src`。(注:错主机绝对直链已由响应层 `normalizePayloadFileUrls` 全局兜底,H5 忘记套也不会打内网 IP;但 App 相对路径展示、以及 `@error` 公网回落仍需按本纪律逐点处理。)
|
||||
|
||||
## 6. 新增字段时的固定步骤
|
||||
|
||||
1. 确认接口返回的字段是路径还是完整 URL(看 uat 实际响应,不要按字段名猜);
|
||||
2. 渲染前套 `absoluteFileUrl`;
|
||||
2. 渲染前套 `absoluteFileUrl`(错主机绝对直链已由响应层 `normalizePayloadFileUrls` 全局兜底,H5 新展示点忘套也不会打内网 IP;App 相对路径展示仍需显式套);
|
||||
3. 图片类再加 `@error` → `wanFileUrl` 一次回落 → 仍失败才占位;
|
||||
4. `npx vue-tsc --noEmit` 退出码 0;
|
||||
5. App 端(尤其内网线路)实际看一次,H5 正常不代表 App 正常。
|
||||
@@ -82,10 +98,10 @@ wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebA
|
||||
- 内网线路取不到:确认手机与开发机同一 Wi-Fi、后端监听局域网地址(不是只监听 `127.0.0.1`)、`D:\Net8\FileServer` 下有该文件;
|
||||
- 回落到公网仍失败:检查 `WebSitePolicy.WebApiRoot`(`.env` 的 `VITE_WEB_API_ROOT`)与 uat 上文件是否存在。
|
||||
|
||||
## 8. 可选的后端根治
|
||||
## 8. 后端根治(可选,与前端兜底并行)
|
||||
|
||||
当前前端已经兜住三种形态,后端改不改都能用。若要少一层兜底,任一即可:
|
||||
当前前端已经兜住三种形态 + 内网 IP 误拼(H5 按 origin 重拼),后端改不改都能用。若要少一层兜底,按优先级:
|
||||
|
||||
- **UAT nginx 反代补 `proxy_set_header Host $host;`(治本)**:`FileTypePolicy.BuildApplicationUrl` 按请求 Host 拼地址,反代不传原始 Host 时 Kestrel 看到的是 `proxy_pass` 目标内网 IP(如 `10.1.1.163`),拼出的绝对地址外部不可达;
|
||||
- 把 `appsettings.json` 的 `ApplicationUrl` 从 `http://localhost:5235` 改成开发机的局域网地址(如 `http://172.16.253.53:5235`),调试态直接给手机可达地址;
|
||||
- 或让 `PackImageScareFileName` 按当前请求的 Scheme+Host 拼前缀(与 `FileTypePolicy.BuildApplicationUrl` 的口径一致),线上也返回绝对地址;
|
||||
- 多实例部署时让各实例的 `PhysicalFileDirectory` 指向同一份共享存储,否则文件只会留在上传它的那一台上。
|
||||
@@ -37,6 +37,7 @@ import { useMediaIntentStore } from "@/stores/mediaIntent";
|
||||
import { useUploadQueueStore } from "@/stores/uploadQueue";
|
||||
import { chooseFiles } from "@/utils/chooseFiles";
|
||||
import { fetchFileBytes, saveFileBytes, toImageUrl, toPlayableUrl } from "@/utils/dmsBytes";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
import { clearPlayback, setPlayback } from "@/utils/playbackBus";
|
||||
import { transferGlyph } from "@/utils/transferGlyph";
|
||||
|
||||
@@ -643,7 +644,8 @@ async function actionPlay(target: FileItem): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (target.kind === "video" && record.Url) {
|
||||
goPlay("video", target.name, record.Url);
|
||||
// 后端 Url 按请求 Host 拼绝对地址,UAT 反代未传原始 Host 时会拼成内网 IP,H5 下不可达:先过 absoluteFileUrl 归一
|
||||
goPlay("video", target.name, absoluteFileUrl(record.Url));
|
||||
return;
|
||||
}
|
||||
await withBusy(async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { computed, ref } from "vue";
|
||||
import { useAuthGuard, useFamilyGroups, useThemeGradient, useThemeTokens } from "@/composables";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
const { t } = useLocale();
|
||||
const { themeBackground } = useThemeGradient();
|
||||
@@ -726,7 +727,8 @@ function groupDesc(group: FamilyGroupEntity): string {
|
||||
<u-switch v-if="canEdit" :model-value="detail.IsOpenDir" :active-color="primary"
|
||||
@change="onToggleOpenDir" />
|
||||
<text v-else class="detail-field__text">
|
||||
{{ detail.IsOpenDir ? t("SharePage.openDirPublic") : t("SharePage.openDirPrivate") }}
|
||||
{{ detail.IsOpenDir ? t("SharePage.openDirPublic") :
|
||||
t("SharePage.openDirPrivate") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -743,8 +745,8 @@ function groupDesc(group: FamilyGroupEntity): string {
|
||||
<!-- 待审行与正式成员同构:头像/姓名/待审核徽章/手机号/申请原因,右侧同意拒绝 -->
|
||||
<view v-for="(req, index) in detail.FamilyGroupRequests" :key="req.UserName || index"
|
||||
class="detail-item detail-member">
|
||||
<image v-if="req.AvatarUrl" class="detail-member__avatar" :src="req.AvatarUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="req.AvatarUrl" class="detail-member__avatar"
|
||||
:src="absoluteFileUrl(req.AvatarUrl)" mode="aspectFill" />
|
||||
<text v-else class="detail-member__avatar detail-member__avatar--placeholder">
|
||||
{{ (req.UserLocalName || req.UserName).slice(0, 1) }}
|
||||
</text>
|
||||
@@ -781,8 +783,8 @@ function groupDesc(group: FamilyGroupEntity): string {
|
||||
<view v-for="(mem, index) in detail.FamilyGroupManagers" :key="mem.UserName || index"
|
||||
class="detail-item detail-member">
|
||||
<!-- 成员头像:有头像显示圆图,未上传时退回姓名首字占位圆(与用户卡同套路) -->
|
||||
<image v-if="mem.AvatarUrl" class="detail-member__avatar" :src="mem.AvatarUrl"
|
||||
mode="aspectFill" />
|
||||
<image v-if="mem.AvatarUrl" class="detail-member__avatar"
|
||||
:src="absoluteFileUrl(mem.AvatarUrl)" mode="aspectFill" />
|
||||
<text v-else class="detail-member__avatar detail-member__avatar--placeholder">
|
||||
{{ (mem.UserLocalName || mem.UserName).slice(0, 1) }}
|
||||
</text>
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import { apiBaseUrl } from "@/common/apiEndpoint";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
|
||||
/**
|
||||
* 本地资源路径(不属于后端文件地址,不能重拼):
|
||||
* - 非 http 的带协议地址:file:// / content:// / res:// / wxfile:// 等(App 选图、WebView 本地资源);
|
||||
* - 无 // 的内联/二进制协议:blob: / data:(H5 选图临时地址);
|
||||
* - uni 应用沙箱目录:_doc/ _www/ _temp/ _code/ _audio/ _video/ _downloads/ 等;
|
||||
* - App 设备绝对路径:/storage /data /sdcard /var /private /tmp 开头(Android/iOS 临时文件)。
|
||||
* 上传头像/身份证后本地预览用的就是这些路径,误拼会打坏预览,一律原样放行。
|
||||
*/
|
||||
function isLocalResource(url: string): boolean {
|
||||
return (
|
||||
/^(?:blob|data):/i.test(url) ||
|
||||
/^(?!https?:)[a-z][a-z0-9+.-]*:\/\//i.test(url) ||
|
||||
/^_[a-z]+\//i.test(url) ||
|
||||
/^\/(?:storage|data|sdcard|var|private|tmp|sys)\//i.test(url)
|
||||
);
|
||||
}
|
||||
|
||||
/** 把相对文件路径拼到指定基址;已是绝对地址或内联数据原样返回(幂等) */
|
||||
function withBase(base: string, path?: string | null): string {
|
||||
const url = String(path ?? "").trim();
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
// 已是绝对地址或内联数据:原样返回
|
||||
// 已是绝对地址、本地资源或内联数据:原样返回
|
||||
if (
|
||||
/^(?:https?:)?\/\//i.test(url) ||
|
||||
url.startsWith("data:") ||
|
||||
url.startsWith("blob:")
|
||||
url.startsWith("blob:") ||
|
||||
isLocalResource(url)
|
||||
) {
|
||||
return url;
|
||||
}
|
||||
@@ -38,6 +56,26 @@ function stripLoopbackHost(url: string): string | null {
|
||||
return matched[1] ?? "/";
|
||||
}
|
||||
|
||||
/** 取任意绝对地址的 path+query;相对路径原样返回 */
|
||||
function toPathAndQuery(url: string): string {
|
||||
const matched = /^https?:\/\/[^/]+(\/.*)?$/i.exec(url);
|
||||
return matched ? (matched[1] ?? "/") : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件地址目标基座(全站唯一口径):
|
||||
* - H5 发布构建锁当前页面 origin(微信只能从公网域名打开,如 https://uat.youjiala.com);
|
||||
* - H5 开发构建(页面 5173 / 后端 5235 不同端口)与 App 都跟当前线路 apiBaseUrl()。
|
||||
*/
|
||||
function fileUrlBase(): string {
|
||||
// #ifdef H5
|
||||
if (!import.meta.env.DEV) {
|
||||
return window.location.origin;
|
||||
}
|
||||
// #endif
|
||||
return apiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端下发的文件地址补成可直接访问的绝对 URL。
|
||||
*
|
||||
@@ -47,13 +85,27 @@ function stripLoopbackHost(url: string): string | null {
|
||||
* App 端 <image src> 收到 / 开头的串会当本地资源路径,直接加载失败(占位灰块);
|
||||
* 音频/视频的 Formatted* 本来就是绝对地址,走这里原样返回,保证幂等。
|
||||
*
|
||||
* 拼接基准取 apiBaseUrl():图片地址跟随「访问线路」的公网/内网切换,与接口同源。
|
||||
* H5(微信内置浏览器等):只有 https://uat.youjiala.com 一条公网线,不得变更——
|
||||
* 不管后端下发相对路径、localhost 回环,还是反代没传原始 Host 时拼出的内网 IP
|
||||
* (如 https://10.1.1.163/FileServer/...,外部浏览器 ERR_CONNECTION_REFUSED),
|
||||
* 一律剥成 path+query 后按当前页面 origin 重拼(页面本就只能从该域名打开)。
|
||||
* H5 开发构建例外:页面在 localhost:5173、后端在 localhost:5235,按 origin 重拼
|
||||
* 会断本地调试,所以开发构建仍按 apiBaseUrl()(宿主机后端)重拼。
|
||||
* App:拼接基准取 apiBaseUrl(),图片地址跟随「访问线路」的公网/内网切换;
|
||||
* 后端 Debug 时拼进去的 localhost 回环主机也在这里剥掉重拼,否则手机上不可达。
|
||||
*/
|
||||
export function absoluteFileUrl(path?: string | null): string {
|
||||
const url = String(path ?? "").trim();
|
||||
// #ifdef H5
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
return withBase(fileUrlBase(), toPathAndQuery(url));
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
const relative = stripLoopbackHost(url);
|
||||
return withBase(apiBaseUrl(), relative ?? url);
|
||||
return withBase(fileUrlBase(), relative ?? url);
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,9 +115,18 @@ export function absoluteFileUrl(path?: string | null): string {
|
||||
* 或后端只听了 loopback、或该实例磁盘上没有这张图时,图片 404;
|
||||
* 调用方在 <image @error> 里用它再试一次,公网能取到就显示,
|
||||
* 取不到才回落成占位图标。
|
||||
*
|
||||
* H5 发布构建本来就锁死公网(页面 origin 即公网域名),无需再回落,原样返回,
|
||||
* 调用方判重后也不会产生多余重试;H5 开发构建仍按 WebApiRoot 换公网供回落。
|
||||
*/
|
||||
export function wanFileUrl(path?: string | null): string {
|
||||
const url = String(path ?? "").trim();
|
||||
// #ifdef H5
|
||||
if (!import.meta.env.DEV) {
|
||||
return url;
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 回环地址:剥成路径再拼公网
|
||||
const relative = stripLoopbackHost(url);
|
||||
if (relative != null) {
|
||||
@@ -78,4 +139,69 @@ export function wanFileUrl(path?: string | null): string {
|
||||
return withBase(WebSitePolicy.WebApiRoot, absolute[1] ?? "/");
|
||||
}
|
||||
return withBase(WebSitePolicy.WebApiRoot, url);
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 后端文件直链的公共路径标记(DMS RequestPath):展示用绝对地址必含它;
|
||||
* 提交用的相对路径 /FileServer/... 与裸物理名(无主机)、blob:/data:/本地路径均不含“主机+/FileServer”,天然被排除 */
|
||||
const FILE_URL_PATH_MARKER = "/FileServer/";
|
||||
|
||||
/** 取绝对 http(s) 地址的主机(小写、含端口);非绝对地址返回 null */
|
||||
function absoluteHost(url: string): string | null {
|
||||
const matched = /^https?:\/\/([^/]+)/i.exec(url);
|
||||
return matched ? matched[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** 命中「错主机的绝对 FileServer 直链」才剥成 path+query 重拼到基座,否则原样返回 */
|
||||
function rebaseMisHostedFileUrl(
|
||||
url: string,
|
||||
base: string,
|
||||
baseHost: string,
|
||||
): string {
|
||||
const host = absoluteHost(url);
|
||||
if (!host || host === baseHost || !url.includes(FILE_URL_PATH_MARKER)) {
|
||||
return url;
|
||||
}
|
||||
return withBase(base, toPathAndQuery(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应层统一收口(单一入口):递归改写后端返回体里「错主机的绝对 FileServer 直链」到目标基座。
|
||||
*
|
||||
* 为什么放在响应层:以后新增展示点即使忘了调 absoluteFileUrl,数据也已在响应层被洗干净,
|
||||
* 不会再打到内网 IP——规则只住在 fileUrl.ts 这一个文件。
|
||||
*
|
||||
* 判据极窄(同时满足才改):绝对 http(s) + 路径含 /FileServer/ + 主机与 fileUrlBase() 不符。
|
||||
* 因此不会误伤:提交用的相对路径 /FileServer/... 与裸物理名(无主机)、blob:/data:/本地临时路径、
|
||||
* 以及本就正确的公网直链(主机相同)都原样保留;只把 UAT 反代没传原始 Host 时拼出的
|
||||
* 内网 IP(如 https://10.1.1.163/FileServer/...)改回可达地址。H5 锁 origin、App 跟线路,一处生效。
|
||||
*
|
||||
* 就地改写并返回同一引用(uni.request 回的是无环 JSON,递归安全)。
|
||||
*/
|
||||
export function normalizePayloadFileUrls<T>(data: T): T {
|
||||
const base = fileUrlBase();
|
||||
const baseHost = absoluteHost(base);
|
||||
if (!baseHost) {
|
||||
return data;
|
||||
}
|
||||
const walk = (value: unknown): unknown => {
|
||||
if (typeof value === "string") {
|
||||
return rebaseMisHostedFileUrl(value, base, baseHost);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
value[i] = walk(value[i]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
obj[key] = walk(obj[key]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return walk(data) as T;
|
||||
}
|
||||
Reference in new issue
Block a user