ow
This commit is contained in:
1 parent
3c0d389fcd
commit
32f237c4bf
22 files changed
+3351
-24
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user