This commit is contained in:
owenchen committed 2026-10-21 20:50:55 +08:00
1 parent 211d2cd253
commit 7fb4503e0e
27 files changed
+2218 -1202

No files matched your search

@@ -0,0 +1,316 @@
<script setup lang="ts">
/**
* 文件选择弹层(音乐明细的封面 / 地址 / 歌词用):
* 复用 useDmsFiles 的「我的网盘」浏览链——目录下钻、返回上级、按名称搜索;
* 只列 accept 指定类型的文件(文件夹始终可进入用于下钻),点文件即回传整条 DMS 记录。
*/
import type { PropType } from "vue";
import type { DmsFileUploadViewDisplayData } from "@/data/auto/dmsFileUploadViewDisplayData";
import type { FileItem, FileKind } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { useDmsFiles, useThemeTokens } from "@/composables";
const props = defineProps({
/** 弹层是否展开 */
visible: {
type: Boolean,
default: false,
},
/** 可选文件类型(folder 不在此列也始终可进入) */
accept: {
type: Array as PropType<FileKind[]>,
default: () => ["image", "audio", "document"],
},
});
const emit = defineEmits<{
/** 关闭弹层(遮罩/关闭按钮/根目录点返回) */
(e: "close"): void;
/** 选中一个文件,回传完整记录(FileName 是提交值,Url 可直接预览) */
(e: "select", record: DmsFileUploadViewDisplayData): void;
}>();
const { t } = useLocale();
const { accentGradient } = useThemeTokens();
const files = useDmsFiles();
const keyword = ref("");
/** 类型过滤:文件夹保留用于下钻,文件只留 accept 内的类型 */
const rows = computed<FileItem[]>(() =>
files.files.value.filter(
file => file.kind === "folder" || props.accept.includes(file.kind),
),
);
const loading = files.loading;
/** 当前目录名(面包屑末项),用于顶部路径展示 */
const currName = computed(
() => files.pathStack.value[files.pathStack.value.length - 1]?.displayName || t("FilePage.myCloud"),
);
// 每次打开都从「我的网盘」根目录重新加载:目录可能被别的操作改过,也避免停在上一轮的下钻位置
watch(
() => props.visible,
(open) => {
if (!open) {
return;
}
keyword.value = "";
void files.init();
},
{ immediate: true },
);
/** 点文件夹下钻,点文件回传并关闭 */
async function onTap(row: FileItem): Promise<void> {
if (row.kind === "folder") {
await files.openFolder(row);
return;
}
const record = files.records.value.get(row.id);
if (!record) {
return;
}
emit("select", record);
emit("close");
}
/** 返回上级;已在根目录则关闭弹层 */
async function onBack(): Promise<void> {
const stayed = await files.back();
if (!stayed) {
emit("close");
}
}
async function onSearch(): Promise<void> {
await files.search(keyword.value);
}
/** 文件类型 -> 图标与配色(与 file-grid-list 同套;文件夹用 CSS 造型,图标库没有 folder) */
const kindVisual: Record<Exclude<FileKind, "folder">, { icon: string; color: string }> = {
image: { icon: "photo-fill", color: "#0ea5e9" },
video: { icon: "play-right-fill", color: "#8b5cf6" },
document: { icon: "file-text-fill", color: "#3b82f6" },
audio: { icon: "volume-up", color: "#f59e0b" },
};
function visualOf(kind: FileKind): { icon: string; color: string } {
return kind === "folder" ? { icon: "file-text-fill", color: "#8f959e" } : kindVisual[kind];
}
// 缩略图加载失败的行记住失败态,回退类型图标(与网盘宫格同一套处理,图被清理时不致白块)
const thumbFailed = ref(new Set<number>());
function onThumbError(row: FileItem): void {
thumbFailed.value.add(row.id);
}
/** 可显缩略图的地址:仅图片类型且后端 Url(Scale 压缩图)有值、未报错 */
function thumbSrcOf(row: FileItem): string {
return row.kind === "image" && row.thumbUrl && !thumbFailed.value.has(row.id) ? row.thumbUrl : "";
}
</script>
<template>
<!-- u-popup 只认 modelValue(v-model),绑 :show 会静默弹不出 -->
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
<view class="file-pick">
<view class="file-pick__head">
<text class="file-pick__title">{{ t("FilePage.pickFile") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="emit('close')" />
</view>
<!-- 当前目录 + 返回上级 -->
<view class="file-pick__bar">
<view class="file-pick__back" @tap="onBack">
<u-icon name="arrow-left" size="28" color="#5c626b" />
</view>
<text class="file-pick__path">{{ currName }}</text>
</view>
<!-- 按名称搜索当前目录(回车或点放大镜) -->
<view class="file-pick__search">
<input v-model="keyword" class="file-pick__search-input" :placeholder="t('FilePage.searchPlaceholder')"
confirm-type="search" @confirm="onSearch">
<view class="file-pick__search-btn" @tap="onSearch">
<u-icon name="search" size="32" color="#8f959e" />
</view>
</view>
<scroll-view scroll-y class="file-pick__list">
<view v-if="loading" class="file-pick__empty">
<text>{{ t("FilePage.loading") }}</text>
</view>
<view v-else-if="!rows.length" class="file-pick__empty">
<text>{{ t("FilePage.emptyText") }}</text>
</view>
<view v-for="row in rows" :key="row.id" class="file-pick__row" @tap="onTap(row)">
<view v-if="row.kind === 'folder'" class="file-pick__folder" :style="{ background: accentGradient }">
<view class="file-pick__folder-tab" :style="{ background: accentGradient }" />
</view>
<!-- 图片行:后端 Url 指向 Scale 压缩缩略(网盘宫格同源),失败回退类型图标 -->
<image v-else-if="thumbSrcOf(row)" class="file-pick__thumb" :src="thumbSrcOf(row)"
mode="aspectFill" @error="onThumbError(row)" />
<u-icon v-else :name="visualOf(row.kind).icon" size="36" :color="visualOf(row.kind).color" />
<view class="file-pick__row-main">
<text class="file-pick__row-name">{{ row.name }}</text>
<text v-if="row.sizeText" class="file-pick__row-meta">{{ row.sizeText }}</text>
</view>
<u-icon v-if="row.kind === 'folder'" name="arrow-right" size="24" color="#c0c4cc" />
</view>
</scroll-view>
<view class="file-pick__safe" />
</view>
</u-popup>
</template>
<style lang="scss" scoped>
/* 底部抽屉统一风格:浅灰面板底 + 白色圆角行 */
.file-pick {
padding: 32rpx 24rpx 0;
background-color: #f2f3f5;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8rpx 24rpx;
}
&__title {
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
&__bar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 24rpx;
border-radius: 16rpx;
background-color: #ffffff;
}
&__back {
display: flex;
align-items: center;
justify-content: center;
}
&__path {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 26rpx;
color: #5c626b;
text-overflow: ellipsis;
white-space: nowrap;
}
&__search {
display: flex;
align-items: center;
gap: 12rpx;
height: 76rpx;
margin-top: 16rpx;
padding: 0 24rpx;
border-radius: 16rpx;
background-color: #ffffff;
}
&__search-input {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: #1f2329;
}
&__search-btn {
display: flex;
align-items: center;
justify-content: center;
}
&__list {
height: 52vh;
margin-top: 16rpx;
}
&__row {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 16rpx;
padding: 24rpx 28rpx;
border-radius: 16rpx;
background-color: #ffffff;
}
&__row-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
/* 图片预览缩略:尺寸与网盘宫格缩略一致 */
&__thumb {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 14rpx;
background-color: rgba(31, 35, 41, 0.04);
}
&__row-name {
overflow: hidden;
font-size: 28rpx;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
&__row-meta {
margin-top: 6rpx;
font-size: 22rpx;
color: #a8adb5;
}
/* 文件夹:纯 CSS 造型(主体 + 左上页签) */
&__folder {
position: relative;
width: 44rpx;
height: 32rpx;
border-radius: 6rpx;
}
&__folder-tab {
position: absolute;
top: -8rpx;
left: 0;
width: 20rpx;
height: 10rpx;
border-radius: 4rpx 4rpx 0 0;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
font-size: 26rpx;
color: #a8adb5;
}
/* 底部留白:贴屏安全区 */
&__safe {
height: calc(32rpx + env(safe-area-inset-bottom));
}
}
</style>
+37 -28
View File
@@ -24,6 +24,7 @@ import dmsFileService from "@/service/master/dmsFileService";
import publishAudioService from "@/service/master/publishAudioService";
import publishPhotoService from "@/service/master/publishPhotoService";
import publishVideoService from "@/service/master/publishVideoService";
import { absoluteFileUrl } from "@/utils/fileUrl";
import { kindOf } from "./useDmsFiles";
const FIND_LIMIT = 999999;
@@ -130,12 +131,15 @@ export function useMediaShow() {
async function loadPhotos(mode: string, key: string): Promise<SlideItem[]> {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.PHOTO, key);
return rows.map((r: any) => ({
url: r.FormattedPic || r.Url,
thumb: r.FormattedPic || r.Url,
full: r.Url || r.FormattedPic,
name: baseName(r.OrgFileName || r.Url),
}));
return rows.map((r: any) => {
const thumb = absoluteFileUrl(r.FormattedPic || r.Url);
return {
url: thumb,
thumb,
full: absoluteFileUrl(r.Url || r.FormattedPic),
name: baseName(r.OrgFileName || r.Url),
};
});
}
let details: any[] = [];
if (key) {
@@ -144,12 +148,17 @@ export function useMediaShow() {
} else {
details = await publishPhotoService.FindPublishPhotoDetail();
}
return (details ?? []).map((d: any) => ({
url: d.FormattedPic || d.FileName,
thumb: d.FormattedPic || d.FileName,
full: d.FileName || d.FormattedPic,
name: baseName(d.FileName) || d.Notice || undefined,
}));
// 相册明细的 FormattedPic/FileName 线上是 / 开头的相对路径(后端只在 Debugger
// 附着时才拼域名),展览用 <image> 直接渲染会加载失败,统一补成绝对地址
return (details ?? []).map((d: any) => {
const thumb = absoluteFileUrl(d.FormattedPic || d.FileName);
return {
url: thumb,
thumb,
full: absoluteFileUrl(d.FileName || d.FormattedPic),
name: baseName(d.FileName) || d.Notice || undefined,
};
});
}
/** 音乐厅曲目 */
@@ -157,9 +166,9 @@ export function useMediaShow() {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.AUDIO, key);
return rows.map((r: any) => ({
url: r.Url,
url: absoluteFileUrl(r.Url),
id: r.FileId,
pic: r.FormattedPic || "",
pic: absoluteFileUrl(r.FormattedPic),
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
@@ -168,9 +177,9 @@ export function useMediaShow() {
// 共享中心跳厅:以共享目录为播放列表(与文件页 list 同形态,数据源换共享接口)
const rows = await shareRows(key, "audio");
return rows.map((r: any) => ({
url: r.Url,
url: absoluteFileUrl(r.Url),
id: r.FileId,
pic: r.FormattedPic || "",
pic: absoluteFileUrl(r.FormattedPic),
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
@@ -180,10 +189,10 @@ export function useMediaShow() {
: await publishAudioService.FindPublishAudioDefault();
const details = (data?.Details ?? []) as any[];
return details.map((d: any) => ({
url: d.FormattedUrl || d.Url,
pic: d.FormattedPic || d.Pic || "",
url: absoluteFileUrl(d.FormattedUrl || d.Url),
pic: absoluteFileUrl(d.FormattedPic || d.Pic),
title: d.Title || baseName(d.Url),
lrc: d.FormattedLrc || d.Lrc || "",
lrc: absoluteFileUrl(d.FormattedLrc || d.Lrc),
name: d.Title || baseName(d.Url),
}));
}
@@ -193,10 +202,10 @@ export function useMediaShow() {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.VIDEO, key);
return rows.map((r: any) => ({
url: r.Url,
url: absoluteFileUrl(r.Url),
id: r.FileId,
m3u8: r.FormattedM3u8 || "",
pic: r.FormattedPic || "",
m3u8: absoluteFileUrl(r.FormattedM3u8),
pic: absoluteFileUrl(r.FormattedPic),
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
@@ -205,10 +214,10 @@ export function useMediaShow() {
// 共享中心跳厅:以共享目录为片源列表(与文件页 list 同形态,数据源换共享接口)
const rows = await shareRows(key, "video");
return rows.map((r: any) => ({
url: r.Url,
url: absoluteFileUrl(r.Url),
id: r.FileId,
m3u8: r.FormattedM3u8 || "",
pic: r.FormattedPic || "",
m3u8: absoluteFileUrl(r.FormattedM3u8),
pic: absoluteFileUrl(r.FormattedPic),
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
@@ -218,9 +227,9 @@ export function useMediaShow() {
: await publishVideoService.FindPublishVideoDefault();
const details = (data?.Details ?? []) as any[];
return details.map((d: any) => ({
url: d.FormattedUrl || d.Url,
m3u8: d.FormattedM3u8 || "",
pic: d.FormattedPic || d.Pic || "",
url: absoluteFileUrl(d.FormattedUrl || d.Url),
m3u8: absoluteFileUrl(d.FormattedM3u8),
pic: absoluteFileUrl(d.FormattedPic || d.Pic),
title: d.Title || baseName(d.Url),
name: d.Title || baseName(d.Url),
}));
@@ -7,13 +7,15 @@
*
* 关键契约(照抄旧 Operation,勿自作主张):
* 1. 列表 FindBySearchCriteria -> photo 用 data.Publishs、audio/video 用 data.Datas;
* 后端已按「有效 且(公开 或 我是成员)」过滤,前端不再筛。
* 后端按「我是所有者 或 公开(OpenIndc=Y)」过滤,前端不再筛。
* 2. 详情 FindByKey(publishName) -> PublishXxxData { PublishXxx, Details[], FamilyGroups[] },
* 明细随 FindByKey 一并回,不单独查明细(展览页才用 FindPublishPhotoDetailByPublishName)。
* 3. 编辑:明细上移/下移/删除、公开群组增删——全部是本地改 data,最后整包 Insert/Update 落库
* (旧 upSon/downSon/deleteSon/addFamilyGroup/deleteFamilyGroup 均无独立后端接口)。
* 4. 保存判定:PublishXxx.CreatedBy 有值走 Update,否则 Insert(新专辑编号由后端生成)。
* 5. 归属:OwnerName !== 当前登录名 -> 只能查看,不能改/删(旧系统同逻辑)。
* 5. 权限三层(后端 IsOwner/IsManager/IsMember 下发,前端只负责显隐):
* 所有者全权;公开群管理员可改信息/管群组/删专辑;普通群成员(含权限继承可见者)只能改明细。
* 非成员且非公开 -> FindByKey 直接抛「没有权限.」。
* 6. 专辑「加入新内容」不在本页——沿用文件页发布链(file.vue onPublishConfirm)。
*
* 三套实体(PublishPhoto/Audio/VideoEntity)公共字段命名一致,故用统一视图访问;
@@ -61,6 +63,11 @@ export interface PublishAlbumGroup {
FamilyName: string;
FamilyLocalName?: string;
Notice?: string;
/* 后端实体把这三个字段声明成非空 string,.NET 会按隐式 [Required] 校验:
整行缺键直接 400,必须显式给空串(新增行语义),见 normalizeGroups */
CreatedBy?: string;
PublishName?: string;
UpdatedBy?: string;
[key: string]: any;
}
@@ -170,12 +177,49 @@ export function usePublishAlbums() {
/** 是否停在专辑详情(false=专辑列表) */
const inDetail = computed(() => !!album.value?.PublishName || creating.value);
/** 归属判定:新建态恒可改;已入库需 OwnerName 命中当前登录名 */
const isOwner = computed(
() => creating.value || (!notOwner.value && !!album.value),
/** 后端下发的归属/群组身份(旧后端无这三个字段时回退按 OwnerName 判定) */
const identity = computed(
() =>
(detail.value ?? {}) as {
IsOwner?: boolean;
IsManager?: boolean;
IsMember?: boolean;
},
);
/** 归属判定:新建态恒可改;已入库优先用后端 IsOwner,旧后端回退 OwnerName 比对 */
const isOwner = computed(() => {
if (creating.value) {
return true;
}
if (!album.value) {
return false;
}
return typeof identity.value.IsOwner === "boolean"
? identity.value.IsOwner
: !notOwner.value;
});
/** 是否为该专辑任一公开群组的管理员(Group 的 IsManager,可删相册/改信息/管理群组) */
const isGroupManager = computed(
() => !creating.value && !!identity.value.IsManager,
);
/** 是否为该专辑任一公开群组的正式成员(含权限继承可见者,仅可改明细) */
const isGroupMember = computed(
() => !creating.value && !!identity.value.IsMember,
);
/** 相册信息/群组增删:所有者或群管理员 */
const canManageAlbum = computed(
() => inDetail.value && (isOwner.value || isGroupManager.value),
);
/** 明细编辑(上移/下移/描述/删除):所有者或群成员 */
const canAmendDetails = computed(
() => inDetail.value && (isOwner.value || isGroupMember.value),
);
/** 兼容旧调用点:canAmend 按「可改明细」口径 */
const canAmend = computed(() => canAmendDetails.value);
/** 删除相册:所有者或群管理员(普通成员只能改明细) */
const canDelete = computed(
() => inDetail.value && (isOwner.value || isGroupManager.value),
);
const canAmend = computed(() => inDetail.value && isOwner.value);
const canDelete = computed(() => inDetail.value && isOwner.value);
// ===== 分段切换 =====
async function setKind(next: PublishKind): Promise<void> {
@@ -335,13 +379,27 @@ export function usePublishAlbums() {
}
// ===== 保存 / 删除 =====
/** 公开群组行规整:后端 PublishXxxFamilyGroupEntity 的 CreatedBy/PublishName/UpdatedBy 是非空 string,
* .NET 把它当隐式 [Required]——整行缺键直接 400「The xxx field is required.」(uat 实测)。
* 补空串才既过模型校验、又能被后端 SavePublishXxxFamilyGroup 的 IsNullOrEmpty(CreatedBy)
* 判定为新增行而真正落库;填当前用户名会被当成已存在行静默丢弃。
* 已有行(FindByKey 回传)自带后端值,?? 不会覆盖。 */
function normalizeGroups(rows: PublishAlbumGroup[]): PublishAlbumGroup[] {
return rows.map((row) => ({
...row,
CreatedBy: row.CreatedBy ?? "",
PublishName: row.PublishName ?? "",
UpdatedBy: row.UpdatedBy ?? "",
}));
}
async function save(): Promise<void> {
const a = adapter();
const submitted = detail.value!;
const entity = submitted[a.entityKey] as PublishAlbumEntity;
entity.PublishLocalName = (entity.PublishLocalName || "").trim();
submitted.Details = submitted.Details ?? [];
submitted.FamilyGroups = submitted.FamilyGroups ?? [];
submitted.FamilyGroups = normalizeGroups(submitted.FamilyGroups ?? []);
const result = entity.CreatedBy
? await a.service.Update(submitted)
: await a.service.Insert(submitted);
@@ -380,7 +438,11 @@ export function usePublishAlbums() {
creating,
isEdit,
isOwner,
isGroupManager,
isGroupMember,
canAmend,
canAmendDetails,
canManageAlbum,
canDelete,
setMyUser,
// list / detail
@@ -7,5 +7,8 @@ export class PublishAudioData
public PublishAudio: PublishAudioEntity = new PublishAudioEntity();
public Details: PublishAudioDetailDisplayData[] = [];
public FamilyGroups: PublishAudioFamilyGroupEntity[] = [];
public IsOwner: boolean =false;
public IsManager: boolean =false;
public IsMember: boolean =false;
}
@@ -5,5 +5,8 @@ export class PublishAudioDetailDisplayData extends PublishAudioDetailEntity
public FormattedUrl: string = "";
public FormattedPic: string = "";
public FormattedLrc: string = "";
public PicName: string = "";
public UrlName: string = "";
public LrcName: string = "";
}
@@ -7,5 +7,8 @@ export class PublishPhotoData
public PublishPhoto: PublishPhotoEntity = new PublishPhotoEntity();
public Details: PublishPhotoDetailDisplayData[] = [];
public FamilyGroups: PublishPhotoFamilyGroupDisplayData[] = [];
public IsOwner: boolean =false;
public IsManager: boolean =false;
public IsMember: boolean =false;
}
@@ -7,5 +7,8 @@ export class PublishVideoData
public PublishVideo: PublishVideoEntity = new PublishVideoEntity();
public Details: PublishVideoDetailDisplayData[] = [];
public FamilyGroups: PublishVideoFamilyGroupEntity[] = [];
public IsOwner: boolean =false;
public IsManager: boolean =false;
public IsMember: boolean =false;
}
+13 -1
View File
@@ -37,7 +37,6 @@
"myTheme": "My Theme",
"mySecurity": "Security",
"familyTree": "Family Trees",
"familyPerson": "Family Members",
"loginAgent": "Agent Login",
"changePassword": "Change Password",
"changeLocalName": "Change Nickname",
@@ -630,6 +629,7 @@
"todoTip": "Coming soon",
"todayLabel": "Today",
"myCloud": "My Cloud",
"pickFile": "Select File",
"loading": "Loading…",
"loadFailed": "Failed to load, please retry",
"retryBut": "Retry",
@@ -790,8 +790,13 @@
"noDesc": "No description",
"noAccess": "You don't have access to this album",
"chipPublic": "Public",
"chipPrivate": "Private",
"chipDefault": "Default",
"chipNotDefault": "Not default",
"albumInfo": "Album Info",
"tabInfo": "Overview",
"tabDetails": "Items",
"tabGroups": "Groups",
"albumNo": "Album No.",
"albumName": "Album Name",
"albumDesc": "Album Description",
@@ -806,6 +811,13 @@
"moveDown": "Down",
"delete": "Delete",
"deleteDetailConfirm": "Remove this item from the album?",
"editDetail": "Edit Item",
"detailNotice": "Item Description",
"detailTitle": "Title",
"detailAuthor": "Singer",
"detailCover": "Cover",
"detailUrl": "Address",
"detailLrc": "Lyrics",
"groups": "Shared Groups",
"groupName": "Group Name",
"groupDesc": "Group Description",
+15 -3
View File
@@ -37,7 +37,6 @@
"myTheme": "我的主题",
"mySecurity": "安全管理",
"familyTree": "族谱管理",
"familyPerson": "家族成员",
"loginAgent": "代理登录",
"changePassword": "修改密码",
"changeLocalName": "修改昵称",
@@ -630,6 +629,7 @@
"todoTip": "功能开发中,敬请期待",
"todayLabel": "今天",
"myCloud": "我的云盘",
"pickFile": "选择文件",
"loading": "加载中…",
"loadFailed": "加载失败,请重试",
"retryBut": "重试",
@@ -790,8 +790,13 @@
"noDesc": "暂无描述",
"noAccess": "该专辑无权限查看",
"chipPublic": "公开",
"chipPrivate": "不公开",
"chipDefault": "默认",
"chipNotDefault": "非默认",
"albumInfo": "专辑信息",
"tabInfo": "概览",
"tabDetails": "明细",
"tabGroups": "群组",
"albumNo": "专辑编号",
"albumName": "专辑名称",
"albumDesc": "专辑描述",
@@ -806,6 +811,13 @@
"moveDown": "下移",
"delete": "删除",
"deleteDetailConfirm": "确定从专辑中移除该明细?",
"editDetail": "编辑明细",
"detailNotice": "明细描述",
"detailTitle": "标题",
"detailAuthor": "歌手",
"detailCover": "封面",
"detailUrl": "地址",
"detailLrc": "歌词",
"groups": "公开群组",
"groupName": "群组名称",
"groupDesc": "群组描述",
@@ -813,7 +825,7 @@
"noGroups": "尚未发布到任何群组",
"noGroupsAvailable": "没有可添加的群组,或都已发布过",
"exhibit": "展览",
"amend": "修改",
"amend": "编辑",
"save": "保存",
"cancel": "取消",
"deleteConfirm": "确定删除专辑「{name}」?明细与群组发布关系一并清除",
@@ -935,4 +947,4 @@
"r404_3": "页面已下线或改名,收藏夹里还是旧地址",
"r404_4": "服务器刚重启、菜单还没加载出来,刷新一次即可"
}
}
}
+91
View File
@@ -0,0 +1,91 @@
# 文件地址与访问线路(图片 / 音频 / 视频 URL 的归一与回落)
适用范围:`src/utils/fileUrl.ts`、`src/common/apiEndpoint.ts`、`src/composables/useMediaShow.ts`、`src/pages/tab/media.vue`,以及后端 `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`CloudBuilder.Web.Entry/Program.cs`、`appsettings.json` 中 `ApplicationUrl` / `FileServiceSettings` 两组配置。
本文只讲「后端下发的文件地址怎么变成端上能直接加载的 URL」,接口地址分层见 [environment.md](./environment.md),请求错误口径见 [error-handling.md](./error-handling.md)。
---
## 1. 一句话结论
后端下发的文件地址有**三种形态**,端上不能直接塞进 `<image src>` / 播放器,必须统一过 `absoluteFileUrl()`;加载失败还要给一次公网回落 `wanFileUrl()`。
## 2. 后端地址的三种形态
以相册明细 `PublishPhotoDetailDisplayData` 为例(`PublishPhoto/FindByKey`):
| 形态 | 什么时候出现 | 样例 | 端上直接用的后果 |
| --- | --- | --- | --- |
| 相对路径 | 线上(`Debugger.IsAttached = false`) | `/FileServer/Scale/DmsFile/003/jpg/x.jpg` | App 当成本地资源路径,加载失败(灰块) |
| 回环绝对地址 | VS 调试,`appsettings.json` 的 `ApplicationUrl = http://localhost:5235` | `http://localhost:5235/FileServer/Scale/...` | 手机上的 localhost 指向手机自己,必然失败 |
| 正常绝对地址 | 音频 / 视频明细、或后端已拼好域名时 | `https://uat.youjiala.com/FileServer/...` | 原样返回即可 |
根因链:
- `PackImageScareFileName(isAttached, fileName)` 里 `isAttached ? ServiceSettingsPolicy 的 ApplicationUrl : ""`,即**只有调试态才拼前缀**;
- 同一方法会把 `Backup` 目录替换成 `Scale`(缩略图),`FindByKey` 里另有一次 `PackImageFileName` 保留原图路径;
- 后端本身是能出静态文件的:`Program.cs` 用 `UseStaticFiles` 把 `FileServiceSettings.PhysicalFileDirectory`(`D:\Net8\FileServer`)挂到 `RequestPath`(`/FileServer`)。所以问题是**地址前缀不对**,不是后端不提供文件。
## 3. 为什么 H5 正常、App 不正常
| 端 | 接口线路 | 图片地址 | 结果 |
| --- | --- | --- | --- |
| H5 dev | `http://localhost:5235`(VS 调试) | 后端拼的 `http://localhost:5235/FileServer/...` | 浏览器与后端同机,localhost 成立,显示正常 |
| App 内网直连 | `http://172.16.253.53:5235`(同一台开发机的局域网地址) | 后端拼的 `http://localhost:5235/FileServer/...` | 手机上 localhost 不可达,灰块 |
| App 公网 | `https://uat.youjiala.com`(非调试) | 相对路径 `/FileServer/...` | 未补全则灰块;补全后正常 |
结论:**H5 的“正常”是假象**,它恰好和调试后端同机;判断修复是否生效要在 App 上验证。
## 4. 归一规则(`src/utils/fileUrl.ts`)
```
stripLoopbackHost(url) 把 localhost / 127.0.0.1 / 0.0.0.0 剥成纯路径;非回环返回 null
withBase(base, path) 相对路径拼到 base;已绝对 / data: / blob: 原样返回(幂等)
absoluteFileUrl(path) stripLoopbackHost 之后按 apiBaseUrl() 重拼 —— 当前线路地址
wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebApiRoot —— 公网地址
```
`apiBaseUrl()` 来自访问线路(`api-endpoint-setting`):`lan` 模式返回用户在「我的 → 访问线路」填的内网地址,否则返回公网(App 开发构建取 `WebApiRootDevApp`,发布构建取 `WebApiRoot`)。
实测映射(Node 侧验证过同一套逻辑):
| 输入 | `absoluteFileUrl`(内网 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/...` | 原样 | 原样(调用方判重后不再重试) |
| `""` / `undefined` | `""` | `""` |
## 5. 消费面:哪些地方必须过
| 位置 | 处理 |
| --- | --- |
| `pages/tab/media.vue` 专辑明细缩略图 | `coverOf()` 过 `absoluteFileUrl`;`@error` 时用 `coverFallback[DetailId] = wanFileUrl(...)` 再试一次,仍失败才 `coverFailed` 显示占位图标 |
| `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`。
## 6. 新增字段时的固定步骤
1. 确认接口返回的字段是路径还是完整 URL(看 uat 实际响应,不要按字段名猜);
2. 渲染前套 `absoluteFileUrl`;
3. 图片类再加 `@error` → `wanFileUrl` 一次回落 → 仍失败才占位;
4. `npx vue-tsc --noEmit` 退出码 0;
5. App 端(尤其内网线路)实际看一次,H5 正常不代表 App 正常。
## 7. 排查清单
- 图片灰块:先打印/抓包看实际 `src`,判断属于上面三种形态中的哪一种;
- 断言图片存在:对 uat 地址做 `HEAD`(`https://uat.youjiala.com/FileServer/Scale/...`、`.../Backup/...` 均应为 200);
- 内网线路取不到:确认手机与开发机同一 Wi-Fi、后端监听局域网地址(不是只监听 `127.0.0.1`)、`D:\Net8\FileServer` 下有该文件;
- 回落到公网仍失败:检查 `WebSitePolicy.WebApiRoot`(`.env` 的 `VITE_WEB_API_ROOT`)与 uat 上文件是否存在。
## 8. 可选的后端根治
当前前端已经兜住三种形态,后端改不改都能用。若要少一层兜底,任一即可:
- 把 `appsettings.json` 的 `ApplicationUrl` 从 `http://localhost:5235` 改成开发机的局域网地址(如 `http://172.16.253.53:5235`),调试态直接给手机可达地址;
- 或让 `PackImageScareFileName` 按当前请求的 Scheme+Host 拼前缀(与 `FileTypePolicy.BuildApplicationUrl` 的口径一致),线上也返回绝对地址;
- 多实例部署时让各实例的 `PhysicalFileDirectory` 指向同一份共享存储,否则文件只会留在上传它的那一台上。
+154
View File
@@ -0,0 +1,154 @@
# 多媒体专辑权限(相册 / 音乐 / 影视)
> 定稿 2026-10:三层角色(所有者 / 群管理员 / 普通群成员)+ 旁观者只读。相册(photo)、音乐(audio)、影视(video)三套逻辑同构,仅实体/仓储类型不同;前后端口径一致,后端为最终裁决。
## 一、角色定义与判定
后端 `PublishXxxService.FindByKey` 恒定下发三个布尔标记,前端一切显隐只认它们:
| 角色 | 判定条件 | 后端标记 | 说明 |
|---|---|---|---|
| **所有者** | `publish_xxx.owner_name == 当前登录 UserName` | `IsOwner=true`(同时 `IsMember/IsManager=true`) | 建单人的全权身份 |
| **群管理员** | 该专辑 `publish_xxx_family_group` 任一 `FamilyName`,在 `family_group_manager` 中存在 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y` | `IsManager=true` | 发布到的群组中任一群的管理员即可 |
| **普通群成员** | 同上但 `ManagerIndc=N`(只要 `ValidIndc=Y`) | `IsMember=true` | 含通过权限继承加入群组的有效成员 |
| **旁观者** | 以上都不是;专辑 `OpenIndc=Y` 时仅可读 | 三者均 false | 私有专辑 `FindByKey` 直接抛「没有权限.」 |
注意:
- 所有者分支不查群表,`IsMember/IsManager` 直接置 true;
- 「权限继承加入的群组」落地为 `family_group_manager.ValidIndc=Y` 的有效成员行,`ManagerIndc` 决定管理员身份;
- 旧后端不返回三标记时,前端 `isOwner` 回退按 `OwnerName` 比对(仅兼容,不应依赖)。
## 二、能力矩阵
| 操作 | 所有者 | 群管理员 | 普通群成员 | 旁观者 |
|---|:---:|:---:|:---:|:---:|
| 查看详情 | ✓ | ✓ | ✓ | 仅 `OpenIndc=Y` |
| 改专辑信息(名称 / 描述 / 公开 / 排序 / 过期) | ✓ | ✓ | ✗ | ✗ |
| 改「设为默认专辑」 | ✓ | ✗ | ✗ | ✗ |
| 明细编辑(上移 / 下移 / 描述 / 删除) | ✓ | ✓ | ✓ | ✗ |
| 群组增删(发布去向) | ✓ | ✓ | ✗ | ✗ |
| 删除整张专辑 | ✓ | ✓ | ✗ | ✗ |
| 展览(播放器) | ✓ | ✓ | ✓ | ✓ |
群管理员具备明细编辑权,因为其 `IsMember` 同时为 true。
## 三、后端实现
三个文件同构:`CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs`。
### 3.1 FindByKey — 身份计算与访问闸门
1. 按 `PublishName` 取专辑,取 `FamilyGroups`(join `family_group` 的展示行);
2. `IsOwner = owner_name == 当前用户`,并先令 `IsMember = IsManager = IsOwner`;
3. 非所有者且 `FamilyGroups` 非空时,取所有 `FamilyName`,查 `family_group_manager` 中 `UserName=自己 且 ValidIndc=Y` 的行:
- 有行 → `IsMember=true`;
- 其中任一行 `ManagerIndc=Y` → `IsManager=true`;
4. 访问闸门:`!IsOwner && OpenIndc != 'Y' && !IsMember` → `ValidatedException("没有权限.")`。
### 3.2 Update — 按角色回写保护字段
1. 按 `PublishName` 查库中原记录,不存在 → 「记录不存在.」;
2. `isOwner = original.OwnerName == 当前用户`;
3. 非所有者:查该专辑群组 `FamilyName` 集合 → 查自己的有效成员行(`ValidIndc=Y`)→ `isMember/isManager`;群组集合为空或无成员行 → 「没有权限.」;
4. 防伪造:无条件回写 `OwnerName / CreatedBy / CreatedDatetime`(以库中为准);
5. **`!isOwner && !isManager`** → 回写展示字段 `PublishLocalName / Notice / OpenIndc / ExpireDatetime / OrderBy`(普通成员只能改明细);
6. **`!isOwner`** → 回写 `DefaultIndc`(默认专辑是所有者个人偏好,群管理员也不能改);
7. 所有者且 `DefaultIndc=Y` → `CancelDefaultIndc`(同一用户其它专辑取消默认);
8. `SavePublishXxxDetails` 所有正式成员都会执行(明细落库);
9. `SavePublishXxxFamilyGroup` 仅 `isOwner || isManager` 执行(普通成员提交的群组数组被忽略)。
> 历史坑:第 5 步条件若写成 `!isManager`,所有者在非所有者分支外 `isManager` 恒为 false,会导致所有者的公开/名称/描述/排序被库中旧值回写(保存成功但值弹回)。三套 Service 必须保持 `!isOwner && !isManager`。
### 3.3 Delete — 所有者或群管理员
1. 按 key 查专辑,不存在 → 「记录不存在.」;
2. `OwnerName != 当前用户 && !IsGroupManagerOfPublish(key)` → 「没有权限.」;
3. 删除主表 + 全部明细 + 全部群组关联行。
### 3.4 IsGroupManagerOfPublish
- 取 `publish_xxx_family_group` 中该专辑的 `FamilyName` 集合;
- 集合为空 → false;
- 在 `family_group_manager` 中查 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y && FamilyName 命中` → 任一存在即 true。
## 四、前端映射
### 4.1 composable 开关(`src/composables/usePublishAlbums.ts`)
```text
identity = detail 上的 IsOwner / IsManager / IsMember(旧后端回退 OwnerName)
isOwner = creating || (IsOwner 是布尔 ? IsOwner : !notOwner)
isGroupManager = !creating && IsManager
isGroupMember = !creating && IsMember
canManageAlbum = inDetail && (isOwner || isGroupManager) // 改信息 / 管群组 / 删专辑
canAmendDetails = inDetail && (isOwner || isGroupMember) // 改明细
canDelete = inDetail && (isOwner || isGroupManager)
```
`canAmend` 保留为 `canAmendDetails` 的别名,兼容旧调用。
### 4.2 页面入口显隐(`src/pages/tab/media.vue`)
| UI 入口 | 显示条件 |
|---|---|
| 概览卡「编辑」文字链 + 底部弹层(名称/描述) | `canManageAlbum` |
| 概览卡「对所有人公开」开关 | `canManageAlbum`(否则显示公开/私有文本) |
| 概览卡「设为默认专辑」开关 | `isOwner`(否则显示默认/非默认文本) |
| 明细行操作区(上移 / 下移 / 编辑 / 删除) | `canAmendDetails` |
| 明细描述编辑弹层保存 | 从操作区进入,本质同 `canAmendDetails` |
| 群组页签「+ 添加群组」与行「删除」 | `canManageAlbum` |
| 头卡删除按钮 | `canDelete` |
| 头卡展览按钮 | 所有人 |
### 4.3 自动保存门控
- `commitNow` / `commitSoon` 在 `!album || creating || !canAmendDetails` 时直接返回;
- 普通群成员改动明细后提交的是整包 `Update`,后端按角色只落明细,展示字段与默认标记强制回写;
- 概览卡两个开关直改后走同一防抖通道(250ms 合并),默认开关由后端取消其它专辑默认。
## 五、列表可见性与访问闸门
`FindBySearchCriteria` 的 where(正常列表,`IsModal=false`):
```text
and (owner_name = 当前用户 or open_indc = 'Y')
```
- 结论:**私有专辑(`OpenIndc=N`)即使发布给群组,也不会出现在多媒体列表**;群成员只能从已公开的专辑进入,再按身份获得改写权;
- `FindByKey` 对私有专辑放行所有者与有效群成员,因此从其他入口(如播放器携带 key)仍可打开;
- 若需要「私有群共享专辑也出现在成员列表」,需在该 where 上追加 `EXISTS` 子查询(`publish_xxx_family_group` + `family_group_manager`),目前未实现。
## 六、涉及文件
| 端 | 文件 | 职责 |
|---|---|---|
| 后端 | `CloudBuilder.Master/Data/PublishPhotoData.cs`、`PublishAudioData.cs`、`PublishVideoData.cs` | 新增 `IsOwner / IsManager / IsMember` 三标记 |
| 后端 | `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs` | FindByKey 身份计算、Update 分角色回写、Delete 放行群管理员、IsGroupManagerOfPublish |
| 前端 | `src/composables/usePublishAlbums.ts` | 权限 computed、保存门控数据链 |
| 前端 | `src/pages/tab/media.vue` | 入口显隐、概览开关直改、明细编辑弹层 |
| 生成 | `src/data/auto/publishPhotoData.ts`、`publishAudioData.ts`、`publishVideoData.ts` | 代码生成器由 Data 类同步,勿手改 |
## 七、测试账号验证口径
以「想家」`PP26101400001`(owner=002,发布群 FM000001,群内 002 为管理员、001 为普通成员)为例:
| 账号 | 列表可见 | 概览「编辑」 | 公开开关 | 默认开关 | 明细操作 | 群组增删 | 删除专辑 |
|---|---|:---:|:---:|:---:|:---:|:---:|:---:|
| 002(所有者) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| 002 以外的群管理员 | 仅公开专辑 | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ |
| 001(普通成员) | 仅公开专辑 | ✗ | ✗(文本) | ✗(文本) | ✓ | ✗ | ✗ |
| 003(非成员) | 仅公开专辑 | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
## 八、变更注意
1. 三套 Service 必须同步改动,任何一套漏改都会造成 photo/audio/video 行为不一致;
2. 「普通成员锁展示字段」条件固定为 `!isOwner && !isManager`,不要简化为 `!isManager`;
3. 前端入口显隐必须与后端校验一致:可见即应成功,不可见即不应发请求;
4. 明细的增删排序没有独立接口,全部随整包 `Update` 落库;群组增删同样走整包提交;
5. `CloudBuilder.Master` 是 DLL 引用链,改动 Data/Service 后按 Master → Master.Service → Web.Entry 顺序重新生成,前端 `data/auto` 才会同步。
## 相关文档
- [permission.md](./permission.md):页面与功能级权限体系(登录态 PermissionContext)
- [family-tree-permission.md](./family-tree-permission.md):族谱权限;群管理员身份的上游 `family_group_manager` 行由其成员关系产生
+2 -8
View File
@@ -109,14 +109,8 @@
{
"path": "pages/security/help-feature-operation"
},
// 家族成员(/family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器
// 族谱管理已融合进首页(pages/dashboard/dashboard 两态页),旧 family-tree-* 两页已退役
{
"path": "pages/security/family-person-enquiry"
},
{
"path": "pages/security/family-person-operation"
},
// 族谱管理已融合进首页(pages/dashboard/dashboard 两态页),旧 family-tree-* 两页已退役;
// 家族成员两页同样已退役(检索/维护已并入族谱设计)
// 底部导航测试页(首页复用上面的 dashboard):文件/多媒体/共享/我的
{
"path": "pages/tab/file"
@@ -1,207 +0,0 @@
<script setup lang="ts">
/**
* 家族成员查询(列表页):旧 WebVue views/master/FamilyPersonEnquiry.vue 的移动端等价物
*
* 保留:四条筛选(谱名/常用名/族谱编号 文本包含、性别 单选枚举)、每页 20 条、行点击进详情。
* 旧页的新增/删除按钮本就被注释禁用,故这里不出新增入口、也不做批量删除,行为对齐。
*
* 本页不再承担选择器职责:族谱首页的「族员搜索 / 选配偶」已改为调用方内嵌
* <admin-pick-popup> 底部弹窗(数据源 composables/usePickerConfigs 的 usePersonPickConfig),
* 遗留的 useAdminPickTarget 协议代码已移除,与其他 enquiry 页同构。
*/
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
import { useLocale } from "uview-pro";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { fillCriteria, makeFilterOption, toSelectOption } from "@/admin/admin-filter";
import { useAdminList, useAuthGuard, useThemeGradient, useThemeTokens } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
import { FamilyPersonSearchCriteria } from "@/data/auto/familyPersonSearchCriteria";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { pageUrl } from "@/router/routes";
import familyPersonService from "@/service/master/familyPersonService";
const { t, currentLocale } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
// 统一风格接线(与族谱首页/媒体/共享页同套):主题渐变底 + 强调色 CSS 变量下发
const { themeBackground } = useThemeGradient();
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
const themeVars = computed(() => ({
"--fg-primary": primary.value,
"--fg-gradient": accentGradient.value,
"--fg-soft": accentSoft.value,
"--fg-shadow": accentShadow.value,
}));
const LABEL_KEYS: Record<string, string> = {
[FamilyPersonEntity.DB_GENEALOGY_NAME]: "FamilyPersonPage.genealogyName",
[FamilyPersonEntity.DB_COMMON_NAME]: "FamilyPersonPage.commonName",
[FamilyPersonEntity.DB_GENDER]: "FamilyPersonPage.gender",
[FamilyPersonEntity.DB_TREE_ID]: "FamilyPersonPage.treeId",
};
const genderChoices = computed(() =>
getPolicyOptions("GenderPolicy").map(o => ({ label: o.label, value: o.key })),
);
const bindOptions = reactive([
makeFilterOption(
FamilyPersonEntity.DB_GENEALOGY_NAME,
t(LABEL_KEYS[FamilyPersonEntity.DB_GENEALOGY_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyPersonEntity.DB_COMMON_NAME,
t(LABEL_KEYS[FamilyPersonEntity.DB_COMMON_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyPersonEntity.DB_GENDER,
t(LABEL_KEYS[FamilyPersonEntity.DB_GENDER]),
SearchFilterTypePolicy.SINGLE_SELECTION,
SearchFilterOperatorPolicy.SINGLE,
genderChoices.value,
),
makeFilterOption(
FamilyPersonEntity.DB_TREE_ID,
t(LABEL_KEYS[FamilyPersonEntity.DB_TREE_ID]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
]);
async function fetchData(query: AdminQuery) {
const criteria = new FamilyPersonSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await familyPersonService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({
bindOptions,
loader: fetchData,
orderby: FamilyPersonEntity.DB_GENEALOGY_NAME,
});
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("FamilyPersonPage.surname"),
prop: FamilyPersonEntity.SURNAME,
},
{
label: t("FamilyPersonPage.genealogyName"),
prop: FamilyPersonEntity.GENEALOGY_NAME,
},
{
label: t("FamilyPersonPage.gender"),
prop: FamilyPersonEntity.GENDER,
format: (row: AdminRow) => getPolicyText("GenderPolicy", row.Gender),
},
{
label: t("FamilyPersonPage.commonName"),
prop: FamilyPersonEntity.COMMON_NAME,
},
{
label: t("FamilyPersonPage.createdDatetime"),
prop: FamilyPersonEntity.CREATED_DATETIME,
date: "datetime",
},
]);
const keyword = ref("");
const listRef = ref<any>(null);
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === FamilyPersonEntity.DB_GENEALOGY_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
uni.navigateTo({
url: `${pageUrl("familyPersonOperation")}?id=${row.PersonId}&mode=view`,
});
}
watch(currentLocale, () => {
for (const option of bindOptions) {
const key = LABEL_KEYS[option.BindValue];
if (key) {
option.DisplayName = t(key);
}
if (option.BindValue === FamilyPersonEntity.DB_GENDER) {
option.SelectOptions = genderChoices.value.map(toSelectOption);
}
}
});
useAuthGuard(() => {
// 每次显示都重拉第一页:详情修改后返回能看到最新数据(navigateBack 不重新挂页面)
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page" :style="[{ background: themeBackground }, themeVars]">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyPersonPage.title')"
:show-nav-back="true" :columns="columns" :loader="state.query" :row-key="FamilyPersonEntity.PERSON_ID"
title-key="GenealogyName" :filter-count="filterCount" :filter-visible="filterVisible"
:filter-items="draft" :search-token="searchToken" @search="onSearch"
@open-filter="state.openFilter()" @close-filter="state.closeFilter()"
@apply-filter="state.applyFilter($event)" @clear-filter="state.clearFiltersAndSearch()"
@row-tap="onRowTap">
</admin-list>
</view>
</template>
<style lang="scss" scoped>
/* 统一风格:主题渐变底由页面根节点承担,内嵌的 admin-list 透出背景 */
.page {
width: 100%;
height: 100vh;
}
.page :deep(.admin-list) {
background-color: transparent;
}
/* 工具条与导航融进渐变底,搜索/筛选做成白色圆角件(对齐首页面板观感) */
.page :deep(.toolbar) {
background-color: transparent;
}
.page :deep(.filter-entry) {
background-color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(31, 35, 41, 0.08);
}
.page :deep(.filter-entry.is-active) {
color: var(--fg-primary, #2b8fff);
background: var(--fg-soft, #eaf4ff);
}
/* 卡片选中描边与勾选态跟主题色 */
.page :deep(.card.is-selected) {
box-shadow: inset 0 0 0 2rpx var(--fg-primary, #2b8fff);
}
.page :deep(.card-check.is-on) {
background-color: var(--fg-primary, #2b8fff);
border-color: var(--fg-primary, #2b8fff);
}
.page :deep(.action-toggle) {
color: var(--fg-primary, #2b8fff);
}
</style>
@@ -1,738 +0,0 @@
<script setup lang="ts">
/**
* 家族成员详情(查看 / 修改):旧 WebVue views/master/FamilyPersonOperation.vue 的移动端等价物
*
* 对齐旧逻辑:
* - FindByKey(PersonId) 取 FamilyPersonData,主对象 FamilyPerson;
* - 头像上传 file-path=FamilyPersonAvatar(旧 FileUpload 的移动端等价:chooseImage 单图 +
* uploadFile 直传 DmsFile/UploadFile,FilePath 走请求头、form 字段名=原始文件名、
* filename=分片三段式物理名并同存 AvatarFilename);
* - 性别是 GenderPolicy 枚举,admin-form 无 select,用 #extra 行点开 option-picker 回写;
* - 身份证上传:#extra 里的身份证块,chooseImage 单图 → 单通道上传同源后端 DmsFile/UploadIdCardFile
* (FilePath 头=idcard 目录、form 字段 side=auto)→ 后端先落盘再 server-to-server 调同 docker 的
* OCR 服务 /api/v1/idcard/recognize,把识别结果回填进返回的 FamilyPersonDisplayData(前端不直连 OCR,避免浏览器跨域);
* 前端拿到返回后:常用名/性别/出生日期 回填各自真实列;民族/住址/身份证号/签发机关/有效期 表内无列、不落库,
* 故不显示为表单字段,而是格式化写入「简介」(introduction 列)随保存落库;
* - 只读门控:!IsCanAmend 且 Privacy!=='Y' 时只显「基本资料」卡、隐藏其余卡与修改/删除;
* - 保存按 CreatedBy 有无决定 Update / Insert。
* 旧页的新增入口在查询页即被注释,本页只承担 view/amend 两态(create 走快速建员弹层)。
*/
import type { AdminFormAction, AdminFormField, AdminFormSection } from "@/admin/admin-types";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
import { useAuthGuard } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonData } from "@/data/auto/familyPersonData";
import { smartBack } from "@/router/routes";
import familyPersonService from "@/service/master/familyPersonService";
const { t } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const data = ref<FamilyPersonData>(new FamilyPersonData());
const person = computed(() => data.value.FamilyPerson);
const readonly = ref(true);
const routeId = ref("");
const genderPickerVisible = ref(false);
const uploading = ref(false);
const idcardUploading = ref(false);
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
const genderLabel = computed(() => getPolicyText("GenderPolicy", person.value.Gender));
/** 只读门控(旧页同款):无权修改且本人未公开详细资料时,只保留基本资料卡 */
const readonlyBasicOnly = computed(
() => routeId.value !== "" && !person.value.IsCanAmend && person.value.Privacy !== "Y",
);
const heroTitle = computed(
() => `${person.value.Surname ?? ""}${person.value.GenealogyName ?? ""}`,
);
const heroChips = computed(() => {
const chips: string[] = [];
if (person.value.PersonId) {
chips.push(`${t("FamilyPersonPage.personId")}: ${person.value.PersonId}`);
}
if (person.value.TreeId) {
chips.push(`${t("FamilyPersonPage.treeId")}: ${person.value.TreeId}`);
}
return chips;
});
const sections = computed<AdminFormSection[]>(() => {
const basic: AdminFormSection = {
title: t("FamilyPersonPage.basicTitle"),
fields: [
{ label: t("FamilyPersonPage.surname"), prop: "Surname", required: true },
{ label: t("FamilyPersonPage.genealogyName"), prop: "GenealogyName", required: true },
{ label: t("FamilyPersonPage.commonName"), prop: "CommonName" },
{ label: t("FamilyPersonPage.alias"), prop: "Alias" },
{ label: t("FamilyPersonPage.zi"), prop: "Zi" },
{ label: t("FamilyPersonPage.hao"), prop: "Hao" },
{ label: t("FamilyPersonPage.birthOrder"), prop: "BirthOrder" },
],
};
if (readonlyBasicOnly.value) {
return [basic];
}
return [
basic,
{
title: t("FamilyPersonPage.familyTitle"),
fields: [
{ label: t("FamilyPersonPage.maritalStatus"), prop: "MaritalStatus" },
{ label: t("FamilyPersonPage.fatherId"), prop: "FatherId" },
{ label: t("FamilyPersonPage.motherName"), prop: "MotherName" },
{ label: t("FamilyPersonPage.school"), prop: "School" },
{ label: t("FamilyPersonPage.major"), prop: "Major" },
{ label: t("FamilyPersonPage.education"), prop: "Education" },
{ label: t("FamilyPersonPage.graduationStatus"), prop: "GraduationStatus" },
{ label: t("FamilyPersonPage.duty"), prop: "Duty" },
],
},
{
title: t("FamilyPersonPage.workTitle"),
fields: [
{ label: t("FamilyPersonPage.title"), prop: "Title" },
{
label: t("FamilyPersonPage.privacy"),
prop: "Privacy",
type: "switch",
display: model => (model.Privacy === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
},
{ label: t("FamilyPersonPage.liveStatus"), prop: "LiveStatus" },
{ label: t("FamilyPersonPage.bindUserId"), prop: "BindUserId" },
{
label: t("FamilyPersonPage.bindUser"),
prop: "IsBindUser",
type: "switch",
display: model => (model.IsBindUser === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
},
{ label: t("FamilyPersonPage.birthText"), prop: "BirthText" },
{ label: t("FamilyPersonPage.birthDate"), prop: "BirthDate", type: "date", date: true },
{ label: t("FamilyPersonPage.deathText"), prop: "DeathText" },
{ label: t("FamilyPersonPage.deathDate"), prop: "DeathDate", type: "date", date: true },
],
},
{
title: t("FamilyPersonPage.introTitle"),
fields: [
{
label: t("FamilyPersonPage.introduction"),
prop: "Introduction",
type: "textarea",
maxlength: 2000,
},
],
},
];
});
const actions = computed<AdminFormAction[]>(() => {
const list: AdminFormAction[] = [];
if (readonlyBasicOnly.value) {
return list;
}
list.push({ key: "amend", label: t("AdminPage.amendBut"), when: "view" });
// 旧页口径:仅未挂族谱(TreeId 为空)的成员允许删除
if (person.value.TreeId === "") {
list.push({
key: "delete",
label: t("AdminPage.deleteBut"),
tone: "error",
confirm: true,
when: "view",
});
}
list.push({ key: "save", label: t("AdminPage.saveBut"), when: "edit" });
list.push({ key: "cancel", label: t("AdminPage.cancelBut"), when: "edit" });
return list;
});
async function load(): Promise<void> {
const result = await familyPersonService.FindByKey(routeId.value);
if (!result?.FamilyPerson?.PersonId) {
uni.showToast({ title: t("AdminPage.notExistTip"), icon: "none" });
return;
}
data.value = result;
}
function onFieldChange(prop: string, value: unknown): void {
if (prop === "Privacy" || prop === "IsBindUser") {
person.value[prop] = value ? "Y" : "N";
return;
}
(person.value as Record<string, any>)[prop] = value;
}
function onGenderConfirm(key: string): void {
person.value.Gender = key;
genderPickerVisible.value = false;
}
/** 头像上传:单图 -> DmsFile/UploadFile(旧 FileUpload 默认通道,FilePath 头指定 FamilyPersonAvatar)。
* 后端按 {Backup}/{FilePath}/{ext}/{分片名} 落盘,FindByKey 的 AvatarUrl 也按此拼 URL,
* 故 AvatarFilename 必须存分片物理名(buildChunkFileName),不能存原始文件名 */
async function onUploadAvatar(): Promise<void> {
if (uploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `avatar-${Date.now()}.png`);
if (!filePath) {
return;
}
try {
uploading.value = true;
const physicalName = buildChunkFileName(fileName);
await uploadFile({
url: "/DmsFile/UploadFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: "FamilyPersonAvatar" },
});
person.value.AvatarFilename = physicalName;
person.value.AvatarUrl = filePath;
uni.showToast({ title: t("FamilyPersonPage.avatarUploadedTip"), icon: "none" });
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.avatarUploadFailed"), icon: "none" });
}
finally {
uploading.value = false;
}
},
});
}
function onClearAvatar(): void {
// H5 本地预览是 blob: URL,清掉引用防泄漏;其余端是本地临时路径,无需回收
if (person.value.AvatarUrl.startsWith("blob:") && typeof URL !== "undefined") {
URL.revokeObjectURL(person.value.AvatarUrl);
}
person.value.AvatarFilename = "";
person.value.AvatarUrl = "";
}
/** 身份证 OCR 性别文本("男"/"女"/"male"/"female")反查 GenderPolicy 存储键:
* 先按 key、再按 label 精确,最后按含「男/女」模糊匹配,全落空返回空串(键由后端语言包决定,不写死) */
function matchGenderKey(text: string): string {
const value = text.trim();
if (!value) {
return "";
}
const options = getPolicyOptions("GenderPolicy");
const exact = options.find(o => o.key === value || o.label === value);
if (exact) {
return exact.key;
}
const lower = value.toLowerCase();
const wantMale = value.includes("男") || lower.includes("male");
const wantFemale = value.includes("女") || lower.includes("female");
if (!wantMale && !wantFemale) {
return "";
}
const hit = options.find((o) => {
const label = `${o.key} ${o.label}`.toLowerCase();
return (wantMale && (o.label.includes("男") || label.includes("male")))
|| (wantFemale && (o.label.includes("女") || label.includes("female")));
});
return hit?.key ?? "";
}
/** 出生日期归一为 yyyy-MM-dd(OCR 常给 "19791111",也兼容带分隔符);不足 8 位数字返回空串 */
function formatBirthToYmd(raw: string): string {
const digits = String(raw ?? "").replace(/\D/g, "");
if (digits.length < 8) {
return "";
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
/** 把后端 UploadIdCardFile 返回的识别信息(FamilyPersonDisplayData)回填到表单:
* 常用名/性别/出生日期有对应 family_person 列,直接回填(性别回传原始文本按 GenderPolicy 反查键,出生归一 yyyy-MM-dd);
* 民族/住址/身份证号/签发机关/有效期限 表里没有列、存不进库,故不作为表单字段显示,
* 而是格式化追加进「简介」(introduction 是真实列,能落库)。字段值兼容 PascalCase 与 camelCase;有任一写入返回 true */
function applyIdCardOcr(info: any): boolean {
if (!info || typeof info !== "object") {
return false;
}
const pick = (pascal: string, camel: string): unknown => info[pascal] ?? info[camel];
let filled = 0;
const name = String(pick("CommonName", "commonName") ?? "").trim();
if (name) {
person.value.CommonName = name;
filled += 1;
}
const genderKey = matchGenderKey(String(pick("Gender", "gender") ?? ""));
if (genderKey) {
person.value.Gender = genderKey;
filled += 1;
}
const ymd = formatBirthToYmd(String(pick("BirthDate", "birthDate") ?? ""));
if (ymd) {
(person.value as Record<string, any>).BirthDate = ymd;
filled += 1;
}
if (upsertIdCardIntro(info)) {
filled += 1;
}
return filled > 0;
}
/** 把无列的身份信息(民族/住址/身份证号/签发机关/有效期限)格式化成一个「身份证识别信息」块写入简介:
* 该块恒追加在简介末尾,重新识别时先截掉旧块再追加,避免重复堆叠;无任何可写项返回 false(不动简介) */
function upsertIdCardIntro(info: any): boolean {
const pick = (pascal: string, camel: string): unknown => info[pascal] ?? info[camel];
const labelMap: Array<[string, string, string]> = [
["nation", "Nation", "nation"],
["address", "Address", "address"],
["idNumber", "IdNumber", "idNumber"],
["issuer", "Issuer", "issuer"],
["validity", "Validity", "validity"],
];
const lines: string[] = [];
for (const [labelKey, pascal, camel] of labelMap) {
const val = String(pick(pascal, camel) ?? "").trim();
if (val) {
lines.push(`${t(`FamilyPersonPage.${labelKey}`)}: ${val}`);
}
}
if (!lines.length) {
return false;
}
const title = t("FamilyPersonPage.idcardIntroTitle");
const block = [title, ...lines].join("\n");
let intro = String(person.value.Introduction ?? "");
const idx = intro.indexOf(title);
if (idx >= 0) {
// 旧识别块恒在末尾:截掉旧块(含其后内容)后再追加新块,避免重复
intro = intro.slice(0, idx).replace(/\s+$/, "");
}
person.value.Introduction = intro ? `${intro}\n\n${block}` : block;
return true;
}
/** 上传身份证:单图 → 同源后端 DmsFile/UploadIdCardFile(后端落盘 idcard 目录 + server-to-server 调 OCR)
* → 用返回的识别信息回填字段;前端不直连 OCR,避免浏览器跨域 */
async function onUploadIdCard(): Promise<void> {
if (idcardUploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `idcard-${Date.now()}.jpg`);
if (!filePath) {
return;
}
try {
idcardUploading.value = true;
// 单通道:本地预览先填,物理名(分片三段式)与头像 AvatarFilename 同口径
const physicalName = buildChunkFileName(fileName);
person.value.IdCardFilename = physicalName;
person.value.IdCardUrl = filePath;
// 上传到后端:FilePath 头=idcard 目录,side=auto;后端落盘后调 OCR 并把识别结果放进返回体
const result = await uploadFile({
url: "/DmsFile/UploadIdCardFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: "idcard" },
formData: { side: "auto" },
});
// 后端返回 FamilyPersonDisplayData,业务响应封装体在 Data/data;容错取内层
const recognized = result?.Data ?? result?.data ?? result;
if (applyIdCardOcr(recognized)) {
uni.showToast({ title: t("FamilyPersonPage.idcardUploadedTip"), icon: "none" });
}
else {
uni.showToast({ title: t("FamilyPersonPage.idcardNoResult"), icon: "none" });
}
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.idcardUploadFailed"), icon: "none" });
}
finally {
idcardUploading.value = false;
}
},
});
}
function onClearIdCard(): void {
if (person.value.IdCardUrl.startsWith("blob:") && typeof URL !== "undefined") {
URL.revokeObjectURL(person.value.IdCardUrl);
}
person.value.IdCardFilename = "";
person.value.IdCardUrl = "";
}
async function onSave(): Promise<void> {
if (!String(person.value.Surname ?? "").trim()) {
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
return;
}
if (!String(person.value.GenealogyName ?? "").trim()) {
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
return;
}
const isUpdate = String(person.value.CreatedBy ?? "").trim() !== "";
const result = await (isUpdate
? familyPersonService.Update(data.value)
: familyPersonService.Insert(data.value));
if (result?.FamilyPerson?.PersonId) {
data.value = result;
routeId.value = result.FamilyPerson.PersonId;
}
readonly.value = true;
uni.showToast({ title: t("AdminPage.savedTip"), icon: "none" });
}
async function onDelete(): Promise<void> {
await familyPersonService.Delete(routeId.value);
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
setTimeout(() => smartBack("mineTab"), 600);
}
function onAction(action: AdminFormAction): void {
switch (action.key) {
case "amend":
readonly.value = false;
break;
case "save":
onSave();
break;
case "delete":
onDelete();
break;
case "cancel":
if (!routeId.value) {
smartBack("mineTab");
return;
}
readonly.value = true;
load();
break;
default:
break;
}
}
function onBack(): void {
smartBack("mineTab");
}
onLoad((options) => {
routeId.value = String(options?.id ?? "");
const mode = String(options?.mode ?? "view");
readonly.value = mode !== "create" && mode !== "amend";
});
let booted = false;
useAuthGuard(() => {
if (booted) {
return;
}
booted = true;
if (routeId.value) {
load();
}
});
</script>
<template>
<admin-form :nav-title="t('FamilyPersonPage.detailTitle')" :show-nav-back="true" :hero-title="heroTitle"
:hero-chips="heroChips" :sections="sections" :model="person" :readonly="readonly" :actions="actions"
@back="onBack" @action="onAction" @field-change="onFieldChange">
<!-- 旧系统同款:头像就在 hero 卡左侧,显示与上传同一处;编辑态右侧出「清除头像」 -->
<template #hero-side="{ readonly: ro }">
<view class="hero-avatar" @tap="!ro && onUploadAvatar()">
<image v-if="person.AvatarUrl" class="hero-avatar__img" :src="person.AvatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" color="#ffffff" />
<view v-if="!ro" class="hero-avatar__mask">
<text v-if="uploading" class="hero-avatar__mask-text">
{{ t("FamilyPersonPage.uploadingTip") }}
</text>
<u-icon v-else name="camera" size="28" color="#ffffff" />
</view>
</view>
</template>
<template #hero-end="{ readonly: ro }">
<view v-if="!ro && (person.AvatarUrl || person.AvatarFilename)" class="hero-clear" @tap="onClearAvatar">
<text class="hero-clear-text">
{{ t("FamilyPersonPage.clearAvatar") }}
</text>
</view>
</template>
<template #extra="{ readonly: ro }">
<!-- 只读门控提示:无修改权限且对方未公开详细资料 -->
<view v-if="readonlyBasicOnly" class="ro-tip">
<text>
{{ t("FamilyPersonPage.readonlyTip") }}
</text>
</view>
<template v-else>
<!-- 性别(admin-form 无 select,枚举行在这里点开的 option-picker 里回写) -->
<view class="group">
<view class="group-card">
<view class="enum-row" :class="{ 'enum-row--tap': !ro }"
@tap="!ro && (genderPickerVisible = true)">
<text class="enum-label">
{{ t("FamilyPersonPage.gender") }}
</text>
<view class="enum-value-box">
<text class="enum-value" :class="{ 'enum-value-empty': !genderLabel }">
{{ genderLabel || t("FamilyPersonPage.genderPlaceholder") }}
</text>
<u-icon v-if="!ro" name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
</view>
</view>
<!-- 身份证上传块(编辑态可传:后端落盘 idcard 目录并 OCR 后回填身份字段) -->
<view class="group">
<view class="group-card idcard-card">
<view class="idcard-head">
<text class="idcard-title">{{ t("FamilyPersonPage.idcardTitle") }}</text>
<text v-if="!ro && (person.IdCardUrl || person.IdCardFilename)" class="idcard-clear"
@tap="onClearIdCard">{{ t("FamilyPersonPage.idcardClear") }}</text>
</view>
<view class="idcard-thumb" :class="{ 'idcard-thumb--tap': !ro }" @tap="!ro && onUploadIdCard()">
<image v-if="person.IdCardUrl" class="idcard-thumb__img" :src="person.IdCardUrl"
mode="aspectFill" />
<view v-else class="idcard-thumb__empty">
<u-icon name="camera" size="40" color="#c4c8cf" />
<text class="idcard-thumb__empty-text">{{ t("FamilyPersonPage.idcardUpload") }}</text>
</view>
<view v-if="!ro" class="idcard-thumb__mask">
<text v-if="idcardUploading" class="idcard-thumb__mask-text">
{{ t("FamilyPersonPage.uploadingTip") }}
</text>
<u-icon v-else-if="person.IdCardUrl" name="camera" size="30" color="#ffffff" />
</view>
</view>
<text class="idcard-hint">{{ t("FamilyPersonPage.idcardHint") }}</text>
</view>
</view>
</template>
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
:empty-text="t('FamilyPersonPage.genderEmpty')" :confirm-text="t('AdminPage.confirmBut')"
@close="genderPickerVisible = false" @confirm="onGenderConfirm" />
</template>
</admin-form>
</template>
<style lang="scss" scoped>
.ro-tip {
padding: 28rpx;
margin-bottom: 28rpx;
font-size: 24rpx;
color: $u-tips-color;
background-color: #ffffff;
border-radius: 20rpx;
}
.group {
margin-bottom: 28rpx;
}
.group-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 12rpx 12rpx;
}
.group-title {
font-size: 24rpx;
color: $u-tips-color;
}
.group-card {
padding: 0 28rpx;
overflow: hidden;
background-color: #ffffff;
border-radius: 20rpx;
}
/* hero 左侧头像(旧系统 ft-hero-avatar 同款):显示与上传同一处,编辑态覆相机遮罩 */
.hero-avatar {
position: relative;
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.18);
border: 4rpx solid rgba(255, 255, 255, 0.55);
border-radius: 50%;
}
.hero-avatar__img {
width: 100%;
height: 100%;
}
.hero-avatar__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.hero-avatar__mask-text {
font-size: 20rpx;
color: #ffffff;
}
/* hero 右侧「清除头像」白胶囊(旧系统同款位) */
.hero-clear {
flex: none;
padding: 10rpx 24rpx;
background-color: rgba(255, 255, 255, 0.22);
border-radius: 999rpx;
}
.hero-clear-text {
font-size: 24rpx;
color: #ffffff;
}
.enum-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
}
.enum-label {
font-size: 28rpx;
color: $u-main-color;
}
.enum-value-box {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.enum-value {
font-size: 26rpx;
color: $u-content-color;
}
.enum-value-empty {
color: $u-tips-color;
}
/* 身份证上传块(编辑态可点,识别后回填身份字段) */
.idcard-card {
padding: 28rpx;
}
.idcard-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.idcard-title {
font-size: 28rpx;
color: $u-main-color;
}
.idcard-clear {
font-size: 24rpx;
color: $u-tips-color;
}
.idcard-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 320rpx;
overflow: hidden;
background-color: #f5f6f8;
border: 2rpx dashed #d6d9de;
border-radius: 16rpx;
}
.idcard-thumb--tap:active {
opacity: 0.7;
}
.idcard-thumb__img {
width: 100%;
height: 100%;
}
.idcard-thumb__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.idcard-thumb__empty-text {
font-size: 26rpx;
color: $u-tips-color;
}
.idcard-thumb__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.idcard-thumb__mask-text {
font-size: 24rpx;
color: #ffffff;
}
.idcard-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
line-height: 1.5;
color: $u-tips-color;
}
</style>
File diff suppressed because it is too large. Load diff
+7 -19
View File
@@ -26,9 +26,13 @@ const loginProfileStore = useLoginProfileStore();
即查「数据级授权」里有没有一条 PermissionName=SecurityUser 且 DependentDetailName=LoginAgent 的行(只看挂没挂,不看 Y/N)。
前端镜像就是 can("none","SecurityUser","LoginAgent")。之前误用 canNone("LoginAgent") 查的是功能级同名行,口径错位——
会出现「入口看得见、一点就 403」。改成同口径后:见得到 == 用得了。 */
const { can } = usePermission();
const { can, canNone } = usePermission();
const canSeeLoginAgent = computed(() => can("none", "SecurityUser", "LoginAgent"));
/* 「安全管理」入口显隐:需 SecurityActor 功能权限(与 PAGE_PERMISSIONS.actorEnquiry 同一权限名)。
没授这个权限的用户看不到入口,也就不存在“进得去但里面全 403”的空壳页 */
const canSeeSecurity = computed(() => canNone("SecurityActor"));
/* 「已认证」判定:与后端 UpdateSecurityUserIdCard 口径一致——UserType=NORMAL_USERS("N") 即视为已认证(身份验证成功后本地快照同步) */
const isVerified = computed(() => loginProfileStore.userType === "N");
@@ -109,11 +113,6 @@ function goSecurity(): void {
uni.navigateTo({ url: pageUrl("securityPage") });
}
/** 进入「家族成员」列表页(/family-person-enquiry);族谱管理已升为首页两态页,不再从这里入口 */
function goFamilyPerson(): void {
uni.navigateTo({ url: pageUrl("familyPersonEnquiry") });
}
/** 进入「代理登录」页(/login-agent):管理员以被代理号身份切换会话 */
function goLoginAgent(): void {
uni.navigateTo({ url: pageUrl("loginAgent") });
@@ -200,8 +199,8 @@ async function handleLogout(): Promise<void> {
</view>
<!-- #endif -->
<!-- 安全管理:进入权限体系基础设定聚合页(/security) -->
<view class="mine-cell" @tap="goSecurity">
<!-- 安全管理:进入权限体系基础设定聚合页(/security);仅授 SecurityActor 功能者可见 -->
<view v-if="canSeeSecurity" class="mine-cell" @tap="goSecurity">
<view class="mine-cell__icon">
<u-icon name="lock-fill" size="30" color="#ffffff" />
</view>
@@ -211,17 +210,6 @@ async function handleLogout(): Promise<void> {
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
<view class="mine-cell" @tap="goFamilyPerson">
<view class="mine-cell__icon">
<u-icon name="account-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.familyPerson") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 代理登录:管理员以被代理号身份切换会话(/login-agent);仅授予 LoginAgent 功能者可见 -->
<view v-if="canSeeLoginAgent" class="mine-cell" @tap="goLoginAgent">
<view class="mine-cell__icon">
+2 -4
View File
@@ -55,10 +55,8 @@ export const PAGE_PATHS = {
// 帮助特性(短地址 /help-feature-enquiry、/help-feature-operation):旧 SecurityHelpFeature 两页迁移
helpFeatureEnquiry: "pages/security/help-feature-enquiry",
helpFeatureOperation: "pages/security/help-feature-operation",
// 族谱管理已融合进首页 dashboard 两态页,旧 family-tree-enquiry/operation 两页已退役(不在 PAGE_PATHS 登记)
// 家族成员(短地址 /family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移;族员选择已改为调用方内嵌弹窗
familyPersonEnquiry: "pages/security/family-person-enquiry",
familyPersonOperation: "pages/security/family-person-operation",
// 族谱管理已融合进首页 dashboard 两态页,旧 family-tree-enquiry/operation 两页已退役(不在 PAGE_PATHS 登记);
// 家族成员两页同样已退役(检索/维护已并入族谱设计)
// 底部导航 tab 页(首页复用 dashboard):先各建一个简单测试页占位
fileTab: "pages/tab/file",
mediaTab: "pages/tab/media",
+81
View File
@@ -0,0 +1,81 @@
import { apiBaseUrl } from "@/common/apiEndpoint";
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
/** 把相对文件路径拼到指定基址;已是绝对地址或内联数据原样返回(幂等) */
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:")
) {
return url;
}
const root = base.replace(/\/+$/, "");
return root + (url.startsWith("/") ? url : `/${url}`);
}
/**
* 剥掉回环主机(localhost / 127.0.0.1 / 0.0.0.0),只留路径;非回环地址返回 null。
*
* 为什么要剥:后端 VS 调试时 PackImageScareFileName 会把 appsettings 的
* ApplicationUrl 拼进图片地址,开发机上配的是 http://localhost:5235。
* 电脑浏览器能开这个地址,手机上 localhost 指向手机自己,图片必然加载失败。
* 剥掉后由调用方按当前访问线路(内网 172.16.x.x / 公网 uat)重新拼。
*/
function stripLoopbackHost(url: string): string | null {
const matched =
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?(\/.*)?$/i.exec(
url,
);
if (!matched) {
return null;
}
return matched[1] ?? "/";
}
/**
* 后端下发的文件地址补成可直接访问的绝对 URL。
*
* 为什么要这一步:相册明细(PublishPhotoDetailDisplayData)线上返回的
* FormattedPic/FileName 是以 / 开头的相对路径——后端 PackImageScareFileName
* 只在 Debugger.IsAttached 为真时才拼 ApplicationUrl,线上不会拼。
* App 端 <image src> 收到 / 开头的串会当本地资源路径,直接加载失败(占位灰块);
* 音频/视频的 Formatted* 本来就是绝对地址,走这里原样返回,保证幂等。
*
* 拼接基准取 apiBaseUrl():图片地址跟随「访问线路」的公网/内网切换,与接口同源。
* 后端 Debug 时拼进去的 localhost 回环主机也在这里剥掉重拼,否则手机上不可达。
*/
export function absoluteFileUrl(path?: string | null): string {
const url = String(path ?? "").trim();
const relative = stripLoopbackHost(url);
return withBase(apiBaseUrl(), relative ?? url);
}
/**
* 同一文件的公网(WebApiRoot,通常是 uat)地址,供加载失败时回落重试。
*
* 用途:App 在内网线路下时,图片基址是内网开发机——手机不在同一 Wi-Fi、
* 或后端只听了 loopback、或该实例磁盘上没有这张图时,图片 404;
* 调用方在 <image @error> 里用它再试一次,公网能取到就显示,
* 取不到才回落成占位图标。
*/
export function wanFileUrl(path?: string | null): string {
const url = String(path ?? "").trim();
// 回环地址:剥成路径再拼公网
const relative = stripLoopbackHost(url);
if (relative != null) {
return withBase(WebSitePolicy.WebApiRoot, relative);
}
// 内网绝对地址(如 http://172.16.253.53:5235/FileServer/...):取路径部分换到公网,
// 否则已经是内网地址时“回落”会变成原地址重试,永远取不到公网上的图
const absolute = /^https?:\/\/[^/]+(\/.*)?$/i.exec(url);
if (absolute) {
return withBase(WebSitePolicy.WebApiRoot, absolute[1] ?? "/");
}
return withBase(WebSitePolicy.WebApiRoot, url);
}