url統一定義

This commit is contained in:
owenchen committed 2026-10-22 09:09:13 +08:00
1 parent 69046e827c
commit c6eb00b406
9 files changed
+228 -65

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">
@@ -1564,7 +1569,7 @@ defineExpose({
<text class="home-card__stitle">{{ t("storage.title") }}</text>
</view>
<text class="home-card__spct" :class="{ 'home-card__spct--over': storageOver }">{{ storagePercentText
}}</text>
}}</text>
</view>
<view class="home-card__bar">
<view class="home-card__bar-fill" :class="{ 'home-card__bar-fill--over': storageOver }"
@@ -1660,7 +1665,7 @@ defineExpose({
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40"
:active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag")
}}</text>
}}</text>
</view>
</view>
</view>
@@ -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">
@@ -1767,12 +1772,12 @@ defineExpose({
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
<view v-if="loaded && currentPerson" class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
}}</text></view>
}}</text></view>
<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">
@@ -1782,7 +1787,7 @@ defineExpose({
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
}}</text>
}}</text>
</view>
</view>
</view>
@@ -1825,12 +1830,12 @@ defineExpose({
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle")
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view>
</view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
@@ -1866,13 +1871,13 @@ defineExpose({
<view class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle")
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text>
</view>
</view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
@@ -1912,7 +1917,7 @@ defineExpose({
<view v-if="data.IsManager && pendingPermissions.length" class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending")
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
</view>
<view v-for="record in pendingPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
@@ -1936,10 +1941,10 @@ defineExpose({
<view v-if="data.IsManager" class="ft-card">
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager")
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
</view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty")
}}</text></view>
}}</text></view>
<view v-for="record in managerPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
@@ -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">
@@ -2174,7 +2179,7 @@ defineExpose({
<view class="person-hero__line">
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.SpouseId, record.Gender))
}}</text>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2211,7 +2216,7 @@ defineExpose({
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ datePart(record.MarryDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2227,7 +2232,7 @@ defineExpose({
<view class="sub-picker">
<text class="sub-picker-text">{{
datePart(record.DivorceDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2313,7 +2318,7 @@ defineExpose({
<view class="person-hero__line">
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.ChildrenId, record.Gender))
}}</text>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2424,7 +2429,7 @@ defineExpose({
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
<text class="rel-sub">{{
record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag")
}}</text>
}}</text>
</view>
<view class="perm-ops">
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
@@ -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">
@@ -2601,7 +2606,7 @@ defineExpose({
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
}}</text></view>
}}</text></view>
<view v-else-if="!importFiles.length" class="ft-empty"><text>{{ t(imScope === "SUB" ?
"FamilyTreePage.exchangeFileEmptySub" : "FamilyTreePage.exchangeFileEmpty") }}</text></view>
<view v-for="file in importFiles" :key="file.FileName" class="ft-exchange__row"
@@ -2622,7 +2627,7 @@ defineExpose({
<view v-if="backupLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading") }}</text>
</view>
<view v-else-if="!backupFiles.length" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeBackupEmpty")
}}</text></view>
}}</text></view>
<view v-for="bk in backupFiles" :key="bk.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedBackup === bk.FileName }" @tap="pickedBackup = bk.FileName">
<view class="ft-exchange__row-main">
@@ -2688,7 +2693,7 @@ defineExpose({
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="cancelTreeEdit"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut")
}}</text>
}}</text>
</view>
</view>
</view>
@@ -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;
}
+26 -10
View File
@@ -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` 指向同一份共享存储,否则文件只会留在上传它的那一台上。
+3 -1
View File
@@ -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 () => {
+7 -5
View File
@@ -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>
+130 -4
View File
@@ -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;
}