This commit is contained in:
owenchen committed 2026-10-13 14:23:10 +08:00
1 parent 3c0d389fcd
commit 32f237c4bf
22 files changed
+3351 -24

No files matched your search

+3
View File
@@ -1,2 +1,5 @@
export * from "./useAdminList";
export * from "./useAdminMenu";
export * from "./useAdminPick";
export * from "./useAuthGuard";
export * from "./useLang";
@@ -0,0 +1,130 @@
/**
* 管理端列表页的共用状态机:筛选条 + 分页取数 + 抽屉草稿
*
* 它取代的是旧 WebVue 每个 Enquiry 页里重复三遍的东西:
* initBindOption()、onSubmit/onPagination/onReload 三件套、以及
* SearchCard 内部维护的 pagingParams/result/isSearched。
* 页面只需要提供两样东西:bindOptions(筛选项登记)和 loader(怎么取数)。
*
* 分工边界(重要):
* - 本 composable 不认识 z-paging,也不认识 uni.*:它只把"一次查询"表达成
* query(page, limit) -> Promise<rows>,翻页/下拉刷新/完成回调都归 admin-list;
* 这样内核逻辑在 node 下可测(见 src/admin/admin-filter.ts),
* 而组件库里那一堆 ref 时序问题不会污染判定逻辑。
* - 筛选走"草稿"模式:抽屉打开时拿到的是一份快照(draft),点确定才 applyDraft 写回
* bindOptions,点取消/关闭直接丢弃。旧 WebVue 是直接改 BindOption 并立即查询,
* 手机上打字中途误触就发请求,体验和数据都脏。
* - draft 是 computed 而不是 ref:admin-list 用 v-if 挂载抽屉,
* 每次打开都重新取快照,不存在"抽屉里留着上一次的半截输入"这种状态。
*/
import type {
AdminFilterDraftItem,
AdminLoader,
AdminLoadResult,
AdminQuery,
} from "@/admin/admin-types";
import type { BindOption } from "@/data/custom/bindOption";
import { computed, ref } from "vue";
import {
activeFilterCount,
applyDraft,
buildFilters,
clearFilters,
toDraft,
} from "@/admin/admin-filter";
export interface UseAdminListOptions {
/** 页面登记的筛选项(引用传入,本 composable 只读写它的取值字段) */
bindOptions: BindOption[];
/** 取数:由页面闭包具体的 service 与 criteria 类型 */
loader: AdminLoader;
/** 每页条数,默认 20,与旧页面的 pagingParams.pageSize 一致 */
pageSize?: number;
/** 默认排序列与方向(后端 orderbytag 只取前 4 个字符,asc/desc) */
orderby?: string;
orderbytag?: string;
}
export function useAdminList(options: UseAdminListOptions) {
const limit = options.pageSize ?? 20;
const total = ref(0);
const busy = ref(false);
const filterVisible = ref(false);
/** 自增即触发 admin-list 重新拉第一页:筛选生效的唯一出口 */
const searchToken = ref(0);
/** 当前已生效的筛选条件(不是草稿),每次请求现算,避免和抽屉改动静偶合 */
const filters = computed(() => buildFilters(options.bindOptions));
const filterCount = computed(() => activeFilterCount(options.bindOptions));
/** 抽屉打开时的初始内容快照 */
const draft = computed(() => toDraft(options.bindOptions));
/** admin-list 的 @query 直接绑这里:补齐 filters / orderby 后转给页面的 loader */
async function query(
page: number,
pageSize: number,
): Promise<AdminLoadResult> {
busy.value = true;
try {
const merged: AdminQuery = {
page,
limit: pageSize || limit,
filters: filters.value,
orderby: options.orderby,
orderbytag: options.orderbytag,
};
const result = await options.loader(merged);
total.value = result.total;
return result;
} finally {
busy.value = false;
}
}
/** 草稿写回并重新查询(回到第 1 页:条件变了还停在第 5 页一定是空列表) */
function applyFilter(items: AdminFilterDraftItem[]): void {
applyDraft(options.bindOptions, items);
filterVisible.value = false;
searchToken.value += 1;
}
/** 清空全部条件并重查:旧页面的 onReset + onSearch 合并 */
function clearFiltersAndSearch(): void {
clearFilters(options.bindOptions);
filterVisible.value = false;
searchToken.value += 1;
}
/** 只关掉抽屉,不改任何已生效的条件 */
function closeFilter(): void {
filterVisible.value = false;
}
function openFilter(): void {
filterVisible.value = true;
}
/** 手动重查当前条件(删除/保存成功后要刷新列表) */
function reload(): void {
searchToken.value += 1;
}
return {
limit,
total,
busy,
filters,
draft,
filterCount,
filterVisible,
searchToken,
query,
openFilter,
closeFilter,
applyFilter,
clearFiltersAndSearch,
reload,
};
}
export type AdminListState = ReturnType<typeof useAdminList>;
@@ -0,0 +1,89 @@
/**
* 首页入口菜单:后端按用户过滤的菜单 + 本地白名单 + 本地权限复核
*
* 数据源选择(实测结论,别再改回去):
* - /SecurityUser/FindMenuByUsername:**按当前用户授权过滤**,返回
* SecurityFunctionResultData.SecurityFunctionDisplayDatas(含 FunctionLocalName /
* FunctionEngName / Icon / Orderby / PermissionName),这是唯一能直接渲染入口的形态。
* 后端链路是 user -> actor -> group -> group_permission -> permission_function_map
* -> function;UserId 为空时**直接返回 null**(不是空数组),这里要吃得下。
* - /SecurityFunction/FindMenus:**不区分用户的全量表**,旧项目只用来注册路由。
* 拿它做菜单会把没授权的功能也列出来,所以这里坚决不用它兜底。
* 菜单拉不到就是拉不到,让用户重试或重新登录,而不是给一个必然 403 的入口。
*
* 两道本地过滤,缺一不可:
* 1. mapMenuEntries 只认 menu-map.ts 白名单里的功能——没移植的页面不该出现入口;
* 2. canVisitPage 再判一次:菜单接口有缓存、档案也可能比菜单新,
* 入口点下去才 403 是最难排查的那种 bug。
*/
import type { AdminMenuEntry } from "@/admin/menu-map";
import type { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplayData";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { mapMenuEntries } from "@/admin/menu-map";
import { UI_LOCALE_EN } from "@/common/locale-const";
import { canVisitPage } from "@/permission/permission-context";
import securityUserService from "@/service/security/securityUserService";
import { useLoginProfileStore } from "@/stores";
/** 模块级缓存:菜单在一次登录会话里基本不变,首页反复 onShow 不该重复打接口 */
let cached: AdminMenuEntry[] | null = null;
/** 缓存归属的账号:换登录人必须重取,否则会把上一个人的入口给下一个人 */
let cachedFor = "";
const entries = ref<AdminMenuEntry[]>([]);
const loading = ref(false);
const errorText = ref("");
/** 取菜单行的数组:后端无权限/未关联用户时返回的是 null 或空对象,都要归一成 [] */
function menuRows(data: any): SecurityFunctionDisplayData[] {
const rows = data?.SecurityFunctionDisplayDatas;
return Array.isArray(rows) ? rows : [];
}
export function useAdminMenu() {
const store = useLoginProfileStore();
const { currentLocale } = useLocale();
const english = computed(
() => (currentLocale.value?.name ?? "") === UI_LOCALE_EN,
);
/** 可见入口:白名单 + 本地权限判定后的结果 */
const visible = computed(() =>
entries.value.filter((item) => canVisitPage(item.page)),
);
async function refresh(force = false): Promise<AdminMenuEntry[]> {
const account = String(store.username ?? "");
if (!force && cached && cachedFor === account) {
entries.value = cached;
return cached;
}
loading.value = true;
errorText.value = "";
try {
const data = await securityUserService.FindMenuByUsername();
const mapped = mapMenuEntries(menuRows(data), english.value);
cached = mapped;
cachedFor = account;
entries.value = mapped;
return mapped;
} catch (error: any) {
// 提示已由 http.interceptor / 全局错误边界统一处理,这里只留状态给页面显示重试
errorText.value = String(error?.message ?? error ?? "");
cached = null;
return entries.value;
} finally {
loading.value = false;
}
}
/** 退出登录后清缓存:否则换个账号登录还会闪一下前一个人的入口 */
function clearCache(): void {
cached = null;
cachedFor = "";
entries.value = [];
}
return { entries, visible, loading, errorText, english, refresh, clearCache };
}
@@ -0,0 +1,108 @@
/**
* 管理端"选择页"回传协议
*
* 旧 WebVue 的做法:Operation 页面里塞一个 `<dragModal>`,内嵌 Enquiry 组件,
* 用 provide/inject(operationToEnquiryMethodKey + isModalUser/selectionTypeUser)
* 双向通信,确定时读 `enquiry.value.keys`。
* 移动端不能照搬:屏幕容不下"表单 + 弹窗 + 表格"三层,而且 uni 的页面之间
* 没有 provide/inject(每个页面是独立实例)。所以改成"真的跳一个选择页,
* 选完 emit + navigateBack",通道名由调用方给定。
*
* 参数一律不做 encodeURIComponent:H5 侧 uni/vue-router 拼 URL 用的是 encodeURI,
* 预编码会把 % 再编成 %25(这个坑在 nav-guard 的 ?from= 上踩过一次,
* 见 src/md/page-routing.md)。因此 channel 与 params 的值只允许
* 字母、数字、下划线、连字符、点、斜杠这类安全字符。
*/
import type { AdminRow } from "@/admin/admin-types";
import type { PageName } from "@/router/routes";
import { onUnmounted, ref } from "vue";
import { pageUrl } from "@/router/routes";
/** 事件名前缀:uni.$emit 是全局总线,加前缀避免与业务事件撞名 */
const EVENT_PREFIX = "admin:pick:";
/** 打开发起方要的东西:目标页 + 透传给它的 query */
export interface AdminPickRequest {
page: PageName;
/** 简单标量参数,如 { multiple: "1" };不允许放 JSON 串或含特殊字符的值 */
params?: Record<string, string>;
}
function buildQuery(params: Record<string, string>): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(params)) {
parts.push(`${key}=${value}`);
}
return parts.join("&");
}
/**
* 调用方侧:打开选择页并登记"选好了怎么办"。
* @param channel 通道名,同一页面的多个选择器用不同通道(如 actor、manager)
*/
export function useAdminPick<T extends AdminRow = AdminRow>(channel: string) {
const eventName = `${EVENT_PREFIX}${channel}`;
let handler: ((rows: T[]) => void) | null = null;
function detach(): void {
if (handler) {
uni.$off(eventName, handler);
handler = null;
}
}
function open(
request: AdminPickRequest,
onPicked: (rows: T[]) => void,
): void {
// 先退订再订阅:同一通道重复打开时若留着旧闭包,上一次的选择逻辑会被再执行一遍
detach();
handler = (rows: T[]) => {
onPicked(rows ?? []);
};
uni.$on(eventName, handler);
const query = buildQuery({ channel, ...(request.params ?? {}) });
uni.navigateTo({ url: `${pageUrl(request.page)}?${query}` });
}
onUnmounted(detach);
return { channel, open, detach };
}
/**
* 选择页侧:onLoad 里把 channel 存下来,确定时回传。
* 没带 channel 参数时按 "default" 处理——手敲地址进来也不会报错,
* 只是没人接收这次选择。
*/
export function useAdminPickTarget() {
const channel = ref("default");
const multiple = ref(false);
/** 从 onLoad 的 options 里读协议参数 */
function readOptions(options?: Record<string, string | undefined>): void {
const raw = String(options?.channel ?? "").trim();
if (raw) {
channel.value = raw;
}
multiple.value = String(options?.multiple ?? "") === "1";
}
/** 回传并退回上一页 */
function deliver(rows: AdminRow[]): void {
uni.$emit(`${EVENT_PREFIX}${channel.value}`, rows);
uni.navigateBack();
}
/** 单选语义:只认第一条 */
function deliverOne(row: AdminRow): void {
deliver([row]);
}
function cancel(): void {
uni.navigateBack();
}
return { channel, multiple, readOptions, deliver, deliverOne, cancel };
}