ow
This commit is contained in:
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>
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "服务器刚重启、菜单还没加载出来,刷新一次即可"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 指向同一份共享存储,否则文件只会留在上传它的那一台上。
|
||||
@@ -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` 行由其成员关系产生
|
||||
@@ -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>
|
||||
+1011
-121
File diff suppressed because it is too large.
Load diff
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user