This commit is contained in:
owenchen committed 2026-10-16 15:49:54 +08:00
1 parent 3b2ee8523b
commit b5d2a2ede0
17 files changed
+2061 -1484

No files matched your search

+27 -116
View File
@@ -1,127 +1,38 @@
<script setup lang="ts">
/**
* 登录后的落地页:后端按用户下发的功能菜单驱动入口网格
*
* 数据链路(详见 composables/useAdminMenu.ts):
* /SecurityUser/FindMenuByUsername(按用户授权)-> menu-map.ts 白名单 -> canVisitPage 复核。
* 三层过滤后剩下的才是能点的入口,绝不用 FindMenus 全量表兜底(会列出未授权功能)。
* 首页壳页面(H5 `/` 入口 + tab 下标 0):族谱管理两态页已拆为
* components/family-tree-home 组件(easycom 自动注册),本页只做两件事:
* 1. 页面级登录守卫(useAuthGuard 依赖 onShow,子组件里不触发,必须留在页面);
* 2. 守卫通过后让组件在列表态重拉数据(详情编辑中不打断,口径由组件 reloadIfList 自定)。
* 时序坑:首次进入时 onShow 早于子组件挂载,homeRef 还是 null——直接空调会被
* 可选链静默吞掉(表现为空态无数据),故未挂载时排队,等组件 emit ready 再补发。
* 后续首页要挂其他内容(功能菜单等),在本页并列追加即可,族谱逻辑不受影响。
*/
import type { PageName } from "@/router/routes";
import { useLocale } from "uview-pro";
import { computed } from "vue";
import { useAdminMenu, useAuthGuard } from "@/composables";
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
import { pageUrl } from "@/router/routes";
import { ref } from "vue";
import { useAuthGuard } from "@/composables";
const { t } = useLocale();
const menu = useAdminMenu();
/** 结构类型即可:只用到组件 defineExpose 的 reloadIfList,避免引组件类型造成循环引用 */
const homeRef = ref<{ reloadIfList: () => void } | null>(null);
/** 守卫已过但组件尚未挂载:记挂一次首拉,ready 时补发 */
let pendingReload = false;
/** 可见入口:useAdminMenu 里已做过白名单 + canVisitPage 两道过滤 */
const items = computed(() => menu.visible.value);
/** 拉取失败(不是"没权限"就是网络/后端异常):给出重试入口而不是空一片 */
const failed = computed(() => !menu.loading.value && Boolean(menu.errorText.value));
function openPage(page: PageName): void {
uni.navigateTo({ url: pageUrl(page) });
}
// 菜单拉取排在登录守卫之后;refresh 自带按账号的模块级缓存,反复 onShow 不会重复打接口
useAuthGuard(() => {
menu.refresh();
if (homeRef.value) {
homeRef.value.reloadIfList();
}
else {
pendingReload = true;
}
});
function onHomeReady(): void {
if (pendingReload) {
pendingReload = false;
homeRef.value?.reloadIfList();
}
}
</script>
<template>
<app-page :nav-title="WebSitePolicy.Title" :show-nav-back="false" :hide-nav="true" :show-tabbar="true">
<view class="dashboard">
<view v-if="menu.loading.value" class="dashboard__tip">
<text>{{ t("AdminPage.menuLoading") }}</text>
</view>
<view v-else-if="failed" class="dashboard__tip">
<text>{{ t("AdminPage.noPermissionText") }}</text>
<u-button class="dashboard__retry" shape="circle" size="mini" :text="t('AdminPage.menuRetryBut')"
@click="menu.refresh(true)" />
</view>
<scroll-view v-else class="dashboard__scroll" scroll-y :show-scrollbar="false">
<view v-if="!items.length" class="dashboard__empty">
<u-empty mode="list" :text="t('AdminPage.menuEmptyText')" />
</view>
<view v-else class="grid">
<view v-for="item in items" :key="item.function" class="grid-item" @click="openPage(item.page)">
<view class="grid-icon">
<u-icon :name="item.icon" size="46" color="var(--u-type-primary)" />
</view>
<text class="grid-title">
{{ item.title }}
</text>
</view>
</view>
</scroll-view>
</view>
</app-page>
<family-tree-home ref="homeRef" @ready="onHomeReady" />
</template>
<style lang="scss" scoped>
.dashboard {
display: flex;
flex-direction: column;
min-height: 60vh;
padding: 20rpx;
}
.dashboard__tip {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
font-size: 26rpx;
color: $u-tips-color;
}
.dashboard__retry {
margin-top: 24rpx;
}
.dashboard__scroll {
flex: 1;
}
.dashboard__empty {
padding: 80rpx 0;
}
.grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.grid-item {
display: flex;
flex-direction: column;
align-items: center;
width: 33.33%;
padding: 28rpx 0;
}
.grid-icon {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
margin-bottom: 12rpx;
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
}
.grid-title {
font-size: 24rpx;
color: $u-content-color;
text-align: center;
}
</style>
@@ -5,7 +5,7 @@
* 保留:四条筛选(谱名/常用名/族谱编号 文本包含、性别 单选枚举)、每页 20 条、行点击进详情。
* 旧页的新增/删除按钮本就被注释禁用,故这里不出新增入口、也不做批量删除,行为对齐。
*
* 兼作「族员选择器」:family-tree-operation 的「族员搜索」带 ?channel=person 跳入,
* 兼作「族员选择器」:首页 dashboard 详情态的「族员搜索」带 ?channel=person 跳入,
* 单选回传后调用方按 PersonId 切换当前族员(useAdminPick / useAdminPickTarget 协议)。
*/
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
@@ -13,7 +13,7 @@ import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { fillCriteria, makeFilterOption, toSelectOption } from "@/admin/admin-filter";
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
import { useAdminList, useAdminPickTarget, useAuthGuard, useThemeGradient, useThemeTokens } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
import { FamilyPersonSearchCriteria } from "@/data/auto/familyPersonSearchCriteria";
@@ -26,6 +26,16 @@ const { t, currentLocale } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const pickTarget = useAdminPickTarget();
// 统一风格接线(与族谱首页/媒体/共享页同套):主题渐变底 + 强调色 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,
}));
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
const isPick = ref(false);
@@ -167,7 +177,7 @@ useAuthGuard(() => {
</script>
<template>
<view class="page">
<view class="page" :style="[{ background: themeBackground }, themeVars]">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyPersonPage.title')"
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyPersonEntity.PERSON_ID"
title-key="GenealogyName" :selectable="isPick && pickTarget.multiple.value" :filter-count="filterCount"
@@ -184,9 +194,42 @@ useAuthGuard(() => {
</template>
<style lang="scss" scoped>
/* 统一风格:主题渐变底由页面根节点承担,内嵌的 admin-list 透出背景 */
.page {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
.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,236 +0,0 @@
<script setup lang="ts">
/**
* 族谱查询(列表页):旧 WebVue views/master/FamilyTreeEnquiry.vue 的移动端等价物
*
* 保留:三条筛选(族谱名称/简介 文本包含、公开? 单选)、每页 20 条、
* 行点击进详情、多选批量删除(旧页面同样是循环 Delete(TreeId))。
* 丢掉:多列网格、列配置、打印。
*
* 挂在「安全管理」枢纽下(族谱属 master 域但按产品要求与权限体系同入口),
* 未登记 PAGE_PERMISSIONS:路由级 fail-open,编辑能力由数据级 IsManager/IsCanAmend 把关。
*/
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { fillCriteria, makeFilterOption } from "@/admin/admin-filter";
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
import { FamilyTreeSearchCriteria } from "@/data/auto/familyTreeSearchCriteria";
import { FamilyTreeViewEntity } from "@/data/auto/familyTreeViewEntity";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { usePagePermission } from "@/permission/permission-context";
import { pageUrl } from "@/router/routes";
import familyTreeService from "@/service/master/familyTreeService";
const { t, currentLocale } = useLocale();
const perm = usePagePermission("familyTreeEnquiry");
const pickTarget = useAdminPickTarget();
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
const isPick = ref(false);
const LABEL_KEYS: Record<string, string> = {
[FamilyTreeViewEntity.DB_TREE_NAME]: "FamilyTreePage.treeName",
[FamilyTreeViewEntity.DB_BRIEF]: "FamilyTreePage.brief",
[FamilyTreeViewEntity.DB_PUBLIC_INDC]: "FamilyTreePage.publicIndc",
};
function yesNo() {
return [
{ label: t("FamilyTreePage.yes"), value: "Y" },
{ label: t("FamilyTreePage.no"), value: "N" },
];
}
const bindOptions = reactive([
makeFilterOption(
FamilyTreeViewEntity.DB_TREE_NAME,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_TREE_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyTreeViewEntity.DB_BRIEF,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_BRIEF]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CN_CONTAIN,
),
makeFilterOption(
FamilyTreeViewEntity.DB_PUBLIC_INDC,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_PUBLIC_INDC]),
SearchFilterTypePolicy.SINGLE_SELECTION,
SearchFilterOperatorPolicy.SINGLE,
yesNo(),
),
]);
async function fetchData(query: AdminQuery) {
const criteria = new FamilyTreeSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await familyTreeService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({
bindOptions,
loader: fetchData,
orderby: FamilyTreeViewEntity.DB_TREE_NAME,
});
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("FamilyTreePage.treeName"),
prop: FamilyTreeViewEntity.TREE_NAME,
},
{
label: t("FamilyTreePage.brief"),
prop: FamilyTreeViewEntity.BRIEF,
},
{
label: t("FamilyTreePage.publicIndc"),
prop: FamilyTreeViewEntity.PUBLIC_INDC,
format: (row: AdminRow) => {
if (row.PublicIndc === "Y") {
return t("FamilyTreePage.yes");
}
return row.PublicIndc === "N" ? t("FamilyTreePage.no") : "";
},
},
{
label: t("FamilyTreePage.ancestorSurname"),
prop: FamilyTreeViewEntity.SURNAME,
},
{
label: t("FamilyTreePage.ancestorGenealogyName"),
prop: FamilyTreeViewEntity.GENEALOGY_NAME,
},
]);
const keyword = ref("");
const listRef = ref<any>(null);
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === FamilyTreeViewEntity.DB_TREE_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
if (isPick.value) {
if (pickTarget.multiple.value) {
return;
}
pickTarget.deliverOne(row);
return;
}
uni.navigateTo({
url: `${pageUrl("familyTreeOperation")}?id=${row.TreeId}&mode=view`,
});
}
function onCreate(): void {
uni.navigateTo({ url: `${pageUrl("familyTreeOperation")}?mode=create` });
}
function onConfirm(rows: AdminRow[]): void {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
pickTarget.deliver(rows);
}
async function onDelete(rows: AdminRow[]): Promise<void> {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
uni.showModal({
title: t("AdminPage.confirmDeleteTitle"),
success: async (res: any) => {
if (!res.confirm) {
return;
}
for (const row of rows) {
await familyTreeService.Delete(String(row.TreeId));
}
listRef.value?.clearSelection();
state.reload();
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
},
});
}
watch(currentLocale, () => {
for (const option of bindOptions) {
const key = LABEL_KEYS[option.BindValue];
if (key) {
option.DisplayName = t(key);
}
}
});
onLoad((options) => {
pickTarget.readOptions(options);
isPick.value = String(options?.channel ?? "").trim().length > 0;
});
useAuthGuard(() => {
// 每次显示都重拉第一页:增/改/删后从详情返回能看到最新数据(navigateBack 不重新挂页面)
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyTreePage.title')"
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyTreeViewEntity.TREE_ID"
title-key="TreeName" :selectable="isPick ? pickTarget.multiple.value : Boolean(perm.canDelete.value)"
:filter-count="filterCount" :filter-visible="filterVisible" :filter-items="draft"
:search-token="searchToken" :keep-selection-on-reload="isPick" @search="onSearch"
@open-filter="state.openFilter()" @close-filter="state.closeFilter()"
@apply-filter="state.applyFilter($event)" @clear-filter="state.clearFiltersAndSearch()" @row-tap="onRowTap">
<template v-if="!isPick && perm.canCreate.value" #toolbar-actions>
<view class="create-entry" @click="onCreate">
<u-icon name="plus" size="40" />
</view>
</template>
<template #actions="{ rows }">
<u-button v-if="isPick" type="primary" shape="circle" size="mini" :text="t('AdminPage.pickDoneBut')"
@click="onConfirm(rows)" />
<u-button v-else-if="perm.canDelete.value" type="error" shape="circle" size="mini"
:text="t('AdminPage.deleteBut')" @click="onDelete(rows)" />
</template>
</admin-list>
</view>
</template>
<style lang="scss" scoped>
.page {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
.create-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-left: 12rpx;
color: $u-type-primary;
background-color: $u-bg-color;
border-radius: 16rpx;
}
</style>
File diff suppressed because it is too large. Load diff
+20 -8
View File
@@ -257,18 +257,29 @@ async function onDelete(): Promise<void> {
});
}
/** 展览:把当前 kind + 专辑编号映射到对应播放器页(mode=view,非 list) */
function onExhibit(): void {
const pageName = kind.value === "photo"
? "photoShow"
: kind.value === "audio" ? "audioHall" : "videoHall";
const name = album.value?.PublishName || "";
/** kind -> 展厅页名 */
function exhibitPageOf(k: PublishKind): "photoShow" | "audioHall" | "videoHall" {
return k === "photo" ? "photoShow" : k === "audio" ? "audioHall" : "videoHall";
}
/** 进对应展厅播放某专辑(mode=view + 专辑编号,非 list) */
function exhibitAlbum(k: PublishKind, name: string): void {
uni.navigateTo({
url: `${pageUrl(pageName)}?mode=view&key=${encodeURIComponent(name)}`,
url: `${pageUrl(exhibitPageOf(k))}?mode=view&key=${encodeURIComponent(name)}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
/** 展览:把当前 kind + 专辑编号映射到对应播放器页(mode=view,非 list) */
function onExhibit(): void {
exhibitAlbum(kind.value, album.value?.PublishName || "");
}
/** 列表点卡片图标:不进编辑态,直接进对应展厅播放该专辑(.stop 阻断卡片点击) */
function onIconExhibit(entity: PublishAlbumEntity): void {
exhibitAlbum(kind.value, entity.PublishName || "");
}
// ===== 明细:上移 / 下移 / 删除 =====
function onMoveDetail(index: number, dir: -1 | 1): void {
moveDetail(index, dir);
@@ -346,7 +357,8 @@ function onRemoveGroup(familyName: string): void {
<text class="media-page__state-text">{{ t("MediaPage.emptyAlbums") }}</text>
</view>
<view v-for="entity in list" :key="entity.PublishName" class="album-card" @tap="onOpenAlbum(entity)">
<view class="album-card__avatar">
<!-- 图标钮:单击直进展厅播放该专辑,.stop 不触发卡片进详情 -->
<view class="album-card__avatar" @tap.stop="onIconExhibit(entity)">
<u-icon :name="kindIcon(kind)" size="48" color="#ffffff" />
</view>
<view class="album-card__main">
+1 -14
View File
@@ -24,12 +24,7 @@ function goSecurity(): void {
uni.navigateTo({ url: pageUrl("securityPage") });
}
/** 进入「族谱管理」列表页(/family-tree-enquiry) */
function goFamilyTree(): void {
uni.navigateTo({ url: pageUrl("familyTreeEnquiry") });
}
/** 进入「家族成员」列表页(/family-person-enquiry) */
/** 进入「家族成员」列表页(/family-person-enquiry);族谱管理已升为首页两态页,不再从这里入口 */
function goFamilyPerson(): void {
uni.navigateTo({ url: pageUrl("familyPersonEnquiry") });
}
@@ -54,14 +49,6 @@ function goFamilyPerson(): void {
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 族谱管理:族谱列表与谱系图维护(/family-tree-enquiry) -->
<view class="mine-cell" @tap="goFamilyTree">
<text class="mine-cell__title">
{{ t("MinePage.familyTree") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
<view class="mine-cell" @tap="goFamilyPerson">
<text class="mine-cell__title">