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

+80
View File
@@ -0,0 +1,80 @@
/**
* 卡片一行的显示文本:format -> date -> 原值,三级兜底
*
* 单独成文件而不写进 admin-list.vue:这是纯展示换算,
* 放进组件里就没法在 node 下断言(本项目目前只有纯函数层可测)。
*/
import type { AdminColumn, AdminRow } from "@/admin/admin-types";
import { formatDate } from "@/admin/admin-date";
/** 空值统一显示成 "-":移动端卡片上空白行会被误认为渲染坏了 */
export const EMPTY_CELL_TEXT = "-";
/**
* 取一行里某列的显示文本。
* @param row 后端返回的行对象
* @param column 列定义
*/
export function cellText(row: AdminRow, column: AdminColumn): string {
if (column.format) {
return column.format(row) || EMPTY_CELL_TEXT;
}
const raw = row?.[column.prop];
if (raw === null || raw === undefined || raw === "") {
return EMPTY_CELL_TEXT;
}
if (column.date) {
return formatDate(raw, column.date === "datetime") || EMPTY_CELL_TEXT;
}
return String(raw);
}
/** 卡片主标题用哪一列:页面没指定就拿第一列 */
export function pickTitleColumn(
columns: AdminColumn[],
titleProp?: string,
): AdminColumn | undefined {
if (!columns.length) {
return undefined;
}
if (!titleProp) {
return columns[0];
}
for (const column of columns) {
if (column.prop === titleProp) {
return column;
}
}
return columns[0];
}
/** 主标题之外的剩余列(卡片正文逐行显示) */
export function restColumns(
columns: AdminColumn[],
titleProp?: string,
): AdminColumn[] {
const title = pickTitleColumn(columns, titleProp);
if (!title) {
return [];
}
const rest: AdminColumn[] = [];
for (const column of columns) {
if (column !== title) {
rest.push(column);
}
}
return rest;
}
/** 行的稳定 key:优先页面指定的 rowKey,取不到就退回数组下标 */
export function rowKeyOf(
row: AdminRow,
keyProp: string,
index: number,
): string {
const raw = row?.[keyProp];
if (raw === null || raw === undefined || raw === "") {
return String(index);
}
return String(raw);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* 管理端的日期取值与显示:不依赖 dayjs,纯函数(可在 node 下直接跑 spec)
*
* 为什么自己写:旧前端的筛选条件用 dayjs 存值(BindOption.DayjsValue),
* 再在提交时 format;移动端选用的原生 `<picker mode="date">` 产出的是
* **"yyyy-MM-dd" 字符串**(uview-pro 0.7.1 没有 u-datetime-picker,别去找),
* 中间那一层 dayjs 反而成了多余依赖。这里只保留真正需要的几种形态:
* 后端要的 "yyyy-MM-dd" / "yyyy-MM-dd HH:mm:ss" 字符串,以及列表上的一行显示文本。
*/
/** 宽松解析:Date / 时间戳 / ISO 字符串都能吃,无效值返回 null(不抛异常) */
export function toDate(
value: Date | number | string | null | undefined,
): Date | null {
if (value === null || value === undefined || value === "") {
return null;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
function pad(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
/**
* 格式化给后端或列表用。
* @param withTime true 时输出 yyyy-MM-dd HH:mm:ss(DateTime 型筛选项要这个形态)
*/
export function formatDate(
value: Date | number | string | null | undefined,
withTime = false,
): string {
const date = toDate(value);
if (!date) {
return "";
}
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)}`;
if (!withTime) {
return day;
}
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
return `${day} ${time}`;
}
/** 区间取值(后端 date_between 要求 Value <= Value1) */
export interface DateRange {
start: string;
end: string;
}
/**
* 最近 N 天的日期区间,供筛选抽屉里的快捷 chip 用。
* 端点取"今天",不是"此刻"——date_between 在后端按日期比较,带时间会让边界日漏数据。
* @param days 含今天往前共几天;小于 1 按 1 处理
* @param today 可注入基准日,便于 spec 断言
*/
export function recentRange(days: number, today = new Date()): DateRange {
const base = toDate(today) ?? new Date();
const span = Math.max(1, Math.trunc(days));
const start = new Date(base.getTime());
start.setDate(start.getDate() - (span - 1));
return { start: formatDate(start), end: formatDate(base) };
}
/**
* 取日期部分(前 10 个字符)。
* 抽屉里的原生 picker 只认 yyyy-MM-dd,而 DateTime 型筛选项存的是带时间的整串,
* 直接把整串塞给 picker 会让它显示成"未选中",用户以为没填。
*/
export function datePart(value: string): string {
return String(value ?? "")
.trim()
.slice(0, 10);
}
/** 区间下界:只有日期时补一天的起点 */
export function dayStart(value: string): string {
const day = datePart(value);
return day ? `${day} 00:00:00` : "";
}
/**
* 区间上界:只有日期时补一天的终点。
* 漏掉这一步的后果是"创建日期在 X ~ Y 之间"查不到 Y 当天创建的人——
* 后端拿 created_datetime(带时分秒) 与 'Y'(等于 Y 00:00:00)比较。
*/
export function dayEnd(value: string): string {
const day = datePart(value);
return day ? `${day} 23:59:59` : "";
}
+440
View File
@@ -0,0 +1,440 @@
/**
* 管理端筛选条件的纯函数层:BindOption[] -> SearchFilter[]
*
* 判定语义逐条对照旧 WebVue 的 `components/basic/SearchCriteria.vue:495-577`
* (getFilters),只做两处适配,都是移动端必须的:
* 1. 不再读 `BindOption.DayjsValue`:旧实现依赖 dayjs 再 format,这里约定
* `Value` / `Value1` 直接放已格式化的字符串(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss),
* 格式化由页面层用 @/admin/admin-date 或 uview 的日期选择器完成;
* 2. 不再读 `BindOption.NumberValue`:那是 a-input-number 的产物,
* 移动端的 u-input(type=number) 产出的就是字符串,统一按 `Value` 处理。
*
* 空条件不提交(与旧实现一致):一条 Value 为空串的 equal_to / in_range 条件
* 发上去等于「字段 = ''」,会把结果集直接清零;旧前端能正常工作正是靠这条过滤。
*/
import type {
AdminFilterChoice,
AdminFilterDraftItem,
AdminFilterKind,
} from "@/admin/admin-types";
import type { AbstractSearchCriteria } from "@/data/auto/abstractSearchCriteria";
import { dayEnd, dayStart, recentRange } from "@/admin/admin-date";
import { SearchFilter } from "@/data/auto/searchFilter";
import { SelectOption } from "@/data/auto/selectOption";
import { BindOption } from "@/data/custom/bindOption";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
/**
* 旧 SearchCriteria 支持、但 SearchFilterTypePolicy 没有收录的类型取值。
* 生成器只把常用的六种写进了 policy,这五种散落在组件的 v-if 里,
* 移植时必须原样保留字符串,否则后端按 Type 分支取值的逻辑会对不上。
*/
export const ADMIN_FILTER_TYPE = {
DATE_TIME: "DateTime",
CHECKBOX: "Checkbox",
RADIO_GROUP: "RadioGroup",
CHECKBOX_GROUP: "CheckboxGroup",
MENU_SINGLE: "MenuSingleSelection",
} as const;
/** policy 里缺失的运算符取值(同样来自旧组件的字面量) */
export const ADMIN_FILTER_OPERATOR = {
DATETIME_BETWEEN: "datetime_between",
} as const;
/**
* 日期区间筛选项的快捷档:手机上敲两个日期太贵,
* 日常排查用到的其实只有"最近几天"这三档。
*/
export const ADMIN_FILTER_QUICK_DAYS = [7, 30, 90];
/** 非空字符串判定:旧实现用的是 length > 0,这里额外 trim 掉纯空格 */
function hasText(value?: string): boolean {
if (typeof value !== "string") {
return false;
}
return value.trim().length > 0;
}
/** 区间型运算符:后端取 Value 作下界、Value1 作上界(抽屉也要用它决定渲染起止两个输入) */
export function isBetweenOperator(operator: string): boolean {
if (operator === SearchFilterOperatorPolicy.BETWEEN) {
return true;
}
if (operator === SearchFilterOperatorPolicy.DATE_BETWEEN) {
return true;
}
return operator === ADMIN_FILTER_OPERATOR.DATETIME_BETWEEN;
}
/** 勾选框 / 单选组:取值落在 BooleanValue 上,而非 Value */
export function isBooleanType(type: string): boolean {
if (type === ADMIN_FILTER_TYPE.CHECKBOX) {
return true;
}
return type === ADMIN_FILTER_TYPE.RADIO_GROUP;
}
/** 三种取值任一非空 */
function hasAnyValue(filter: SearchFilter): boolean {
if (hasText(filter.Value) || hasText(filter.Value1)) {
return true;
}
return filter.Values.length > 0;
}
/** 按旧实现的字段映射产出一条筛选条件 */
function toFilter(option: BindOption): SearchFilter {
const filter = new SearchFilter();
filter.Column = option.BindValue;
filter.Type = option.Type;
filter.Operator = option.Operator;
filter.Value = option.Value;
filter.Value1 = option.Value1;
filter.Values = option.Values;
return filter;
}
/**
* 单个 BindOption -> SearchFilter;返回 null 表示这条不该提交。
* 分支顺序与旧 getFilters 的 switch 完全一致,方便逐条比对着排错。
*/
export function buildFilter(option: BindOption): SearchFilter | null {
const filter = toFilter(option);
const between = isBetweenOperator(filter.Operator);
switch (filter.Type) {
case SearchFilterTypePolicy.TEXT:
case SearchFilterTypePolicy.TEXT_CONTAIN:
return hasText(filter.Value) ? filter : null;
case SearchFilterTypePolicy.NUMBER:
case SearchFilterTypePolicy.DATE:
case ADMIN_FILTER_TYPE.DATE_TIME:
// 区间型:只填了左边界也要发,后端把空 Value1 当"不封顶"
if (!between) {
return hasText(filter.Value) ? filter : null;
}
return hasAnyValue(filter) ? filter : null;
case ADMIN_FILTER_TYPE.CHECKBOX:
case ADMIN_FILTER_TYPE.RADIO_GROUP:
if (option.BooleanValue) {
filter.Value = String(option.BooleanValue);
return filter;
}
return null;
case ADMIN_FILTER_TYPE.CHECKBOX_GROUP:
if (option.BooleanValues.length > 0) {
filter.Values = [];
for (const value of option.BooleanValues) {
filter.Values.push(String(value));
}
return filter;
}
return null;
case ADMIN_FILTER_TYPE.MENU_SINGLE:
// 旧实现:级联菜单的值形如 "叶子 - 父级",提交前只取叶子
if (hasText(filter.Value)) {
filter.Value = filter.Value.split(" - ")[0];
return filter;
}
return null;
default:
// SingleSelection / MultipleSelection 等走这里:三种值任一非空即提交
return hasAnyValue(filter) ? filter : null;
}
}
/** 整条筛选条 -> SearchFilter[],顺序保持登记顺序(后端不依赖顺序,便于对比日志) */
export function buildFilters(options: BindOption[]): SearchFilter[] {
const filters: SearchFilter[] = [];
for (const option of options) {
const filter = buildFilter(option);
if (filter) {
filters.push(filter);
}
}
return filters;
}
/** 已填写的条数:筛选按钮上的角标用,0 表示"没过滤,看到的是全量" */
export function activeFilterCount(options: BindOption[]): number {
return buildFilters(options).length;
}
/** 清空一条筛选条件的所有取值(旧 SearchCriteria 的 onReset 同款) */
export function clearFilterValue(option: BindOption): void {
option.Value = "";
option.Value1 = "";
option.Values = [];
option.NumberValue = null!;
option.NumberValue1 = null!;
option.NumberValues = [];
option.DayjsValue = null!;
option.DayjsValue1 = null!;
option.DayjsValues = [];
option.BooleanValue = false;
option.BooleanValue1 = false;
option.BooleanValues = [];
}
/** 整条筛选条清空,返回原数组便于链式调用 */
export function clearFilters(options: BindOption[]): BindOption[] {
for (const option of options) {
clearFilterValue(option);
}
return options;
}
/** 分页与排序参数(旧页面的 pagingParams + orderby/orderbytag 归一成一个入参对象) */
export interface AdminPageQuery {
page: number;
limit: number;
orderby?: string;
orderbytag?: string;
}
/**
* 把筛选条件与分页写进 criteria。
* criteria 由页面自己 new(各域的 SearchCriteria 子类不同),这里只碰
* AbstractSearchCriteria 上的公共字段,保持与旧实现一致的"每页重建 criteria"习惯。
*/
export function fillCriteria<T extends AbstractSearchCriteria>(
criteria: T,
filters: SearchFilter[],
query: AdminPageQuery,
): T {
criteria.Filter = filters;
criteria.page = query.page;
criteria.limit = query.limit;
criteria.orderby = query.orderby ?? "";
criteria.orderbytag = query.orderbytag ?? "";
return criteria;
}
/** 该筛选项在抽屉里是否要渲染成"起止两个值" */
export function isRangeOption(option: BindOption): boolean {
return isBetweenOperator(option.Operator);
}
function pickOption(
options: BindOption[],
key: string,
): BindOption | undefined {
for (const option of options) {
if (option.BindValue === key) {
return option;
}
}
return undefined;
}
function toChoices(option: BindOption): AdminFilterChoice[] {
const choices: AdminFilterChoice[] = [];
for (const item of option.SelectOptions ?? []) {
choices.push({ label: item.DisplayName, value: item.BindValue });
}
return choices;
}
/**
* BindOption -> 抽屉草稿。
* 布尔型(Checkbox / RadioGroup / CheckboxGroup)在旧 UI 里是勾选框,
* 这里统一映射成"选中即字符串 'true',未选即空串",好让 buildFilter 不用改动。
*/
export function toDraftItem(option: BindOption): AdminFilterDraftItem {
const item: AdminFilterDraftItem = {
key: option.BindValue,
label: option.DisplayName,
type: option.Type,
operator: option.Operator,
value: option.Value ?? "",
value1: option.Value1 ?? "",
values: [...(option.Values ?? [])],
choices: toChoices(option),
};
if (isBooleanType(option.Type)) {
item.value = option.BooleanValue ? "true" : "";
}
if (option.Type === ADMIN_FILTER_TYPE.CHECKBOX_GROUP) {
item.values = [];
for (const value of option.BooleanValues ?? []) {
if (value) {
item.values.push(String(value));
}
}
}
return item;
}
/** 整条筛选条 -> 草稿数组(抽屉打开时调用) */
export function toDraft(options: BindOption[]): AdminFilterDraftItem[] {
const draft: AdminFilterDraftItem[] = [];
for (const option of options) {
draft.push(toDraftItem(option));
}
return draft;
}
/** 草稿 -> 写回 BindOption(点"确定"时调用),未登记的 key 直接忽略 */
export function applyDraft(
options: BindOption[],
draft: AdminFilterDraftItem[],
): BindOption[] {
for (const item of draft) {
const option = pickOption(options, item.key);
if (!option) {
continue;
}
// DateTime 型统一补成整日的起止秒:原生 picker 只给到"日",
// 而 datetime_between 的上界若不带时间会被当成 00:00:00,边界日整天漏数据
if (draftKind(item) === "datetime") {
item.value = dayStart(item.value);
item.value1 = dayEnd(item.value1);
}
option.Value = item.value;
option.Value1 = item.value1;
option.Values = [...item.values];
if (isBooleanType(item.type)) {
option.BooleanValue = item.value === "true";
}
if (item.type === ADMIN_FILTER_TYPE.CHECKBOX_GROUP) {
option.BooleanValues = [];
for (const value of item.values) {
option.BooleanValues.push(value === "true");
}
}
}
return options;
}
/**
* 抽屉里一条筛选项该渲染成哪种控件。
* 分类放在纯函数层而不是模板里,是因为模板里的 v-if 链一旦写错,
* 移动端上"看起来能填、提交上去条件丢了"这类问题极难发现。
* - switch:Checkbox / RadioGroup(布尔值)
* - single / multiple:有候选项的选择(MenuSingleSelection 与 SingleSelection 归 single)
* - date / datetime:走原生 picker,DateTime 额外补时分秒
* - number / text:其余都落到输入框
*/
export function draftKind(item: AdminFilterDraftItem): AdminFilterKind {
if (isBooleanType(item.type)) {
return "switch";
}
switch (item.type) {
case SearchFilterTypePolicy.NUMBER:
return "number";
case SearchFilterTypePolicy.DATE:
return "date";
case ADMIN_FILTER_TYPE.DATE_TIME:
return "datetime";
case ADMIN_FILTER_TYPE.CHECKBOX_GROUP:
case SearchFilterTypePolicy.MULTIPLE_SELECTION:
return "multiple";
case ADMIN_FILTER_TYPE.MENU_SINGLE:
case SearchFilterTypePolicy.SINGLE_SELECTION:
return "single";
default:
return item.choices.length > 0 ? "single" : "text";
}
}
/** 该草稿项是否需要"起止两个值" */
export function isRangeDraft(item: AdminFilterDraftItem): boolean {
return isBetweenOperator(item.operator);
}
/** 多选候选项是否已勾选 */
export function isDraftValueChecked(
item: AdminFilterDraftItem,
value: string,
): boolean {
for (const existing of item.values) {
if (existing === value) {
return true;
}
}
return false;
}
/** 多选候选项取反(草稿是普通对象,直接改 items 即可被抽屉感知) */
export function toggleDraftValue(
item: AdminFilterDraftItem,
value: string,
): void {
const next: string[] = [];
let removed = false;
for (const existing of item.values) {
if (existing === value) {
removed = true;
continue;
}
next.push(existing);
}
if (!removed) {
next.push(value);
}
item.values = next;
}
/** 快捷区间:把日期型草稿项写成"最近 N 天"(含今天) */
export function setDraftQuickRange(
item: AdminFilterDraftItem,
days: number,
): void {
const range = recentRange(days);
item.value = range.start;
item.value1 = range.end;
}
/** 单项清空:抽屉里每条筛选项右侧的 x 按钮 */
export function clearDraftItem(item: AdminFilterDraftItem): void {
item.value = "";
item.value1 = "";
item.values = [];
}
/** 草稿项是否已填写(决定 x 按钮和角标显不显示) */
export function isDraftFilled(item: AdminFilterDraftItem): boolean {
if (hasText(item.value) || hasText(item.value1)) {
return true;
}
return item.values.length > 0;
}
/** 候选项 -> 后端 SelectOption(字段名不同:DisplayName / BindValue) */
export function toSelectOption(choice: AdminFilterChoice): SelectOption {
const option = new SelectOption();
option.DisplayName = choice.label;
option.BindValue = choice.value;
return option;
}
/**
* 建一条筛选项,取代旧 hooks/useBindOption.ts 的 getBindOption。
* 不再搬 lg/sm:那是 a-row 栅格宽度,移动端抽屉是单列流,没有对应概念。
* displayName 必须由页面用 t() 传进来,这里不写死中文——旧页面直接写中文字面量,
* 是因为它只跑中文;本项目三端多语言,条件名漏翻会在英文环境下露出中文。
*/
export function makeFilterOption(
bindValue: string,
displayName: string,
type: string,
operator: string,
choices: AdminFilterChoice[] = [],
): BindOption {
const option = new BindOption();
option.BindValue = bindValue;
option.DisplayName = displayName;
option.Type = type;
option.Operator = operator;
for (const choice of choices) {
option.SelectOptions.push(toSelectOption(choice));
}
return option;
}
+159
View File
@@ -0,0 +1,159 @@
/**
* 管理端模板的公共形状定义
*
* 单列出来只为一个原因:admin-list / admin-form / useAdminList 三者要共享这些类型,
* 放进任何一个 .vue 里都会变成循环引用。
*/
import type { SearchFilter } from "@/data/auto/searchFilter";
/** 后端返回的行对象(各域的 Entity 字段大小写不一,这里只能给宽松形状) */
export type AdminRow = Record<string, any>;
/**
* 卡片上的一行显示项。
* 取值顺序:format 有值用 format -> date 用 formatDate -> 否则直接读 row[prop]。
* 之所以要 prop 字符串而不是 getter:卡片列在模板里要参与 v-for key 与选中判定。
*/
export interface AdminColumn {
label: string;
prop: string;
/** 日期型字段:显示成 yyyy-MM-dd(true)或 yyyy-MM-dd HH:mm:ss("datetime") */
date?: boolean | "datetime";
format?: (row: AdminRow) => string;
}
/** 一次列表请求的入参 */
export interface AdminQuery {
page: number;
limit: number;
filters: SearchFilter[];
orderby?: string;
orderbytag?: string;
}
/** 一次列表请求的出参:total 给分页用,rows 是当前页 */
export interface AdminLoadResult<T = AdminRow> {
rows: T[];
total: number;
}
/** 选择型筛选项的一个候选(来自 BindOption.SelectOptions) */
export interface AdminFilterChoice {
label: string;
value: string;
}
/**
* 抽屉里一条筛选项的渲染形态,由 admin-filter 的 draftKind 判定。
* 模板只按这个枚举分支,不再自己解析 Type/Operator,
* 免得"能填但条件丢"这种 bug 分散在模板和纯函数两处。
*
* 写成 const 对象 + keyof 而不是字面量联合:七个成员一行放不下,
* 而 Prettier 会把超长的联合折成前导竖线多行形式,
* 与 ESLint 的 operator-linebreak(要求 = 在行首)直接对撞,改一次撞一次。
*/
export const ADMIN_FILTER_KIND = {
TEXT: "text",
NUMBER: "number",
DATE: "date",
DATETIME: "datetime",
SWITCH: "switch",
SINGLE: "single",
MULTIPLE: "multiple",
} as const;
type FilterKinds = typeof ADMIN_FILTER_KIND;
export type AdminFilterKind = FilterKinds[keyof FilterKinds];
/**
* 一条筛选项在抽屉里的草稿。
* 存在的理由:抽屉是"编辑完点确定才生效"的交互,若直接改 BindOption,
* 用户点取消就留下半截条件;且 UI 只该看到它需要的那几个字段,
* 不必知道 dayjs / number / boolean 三套并存的取值历史。
*/
export interface AdminFilterDraftItem {
/** BindOption.BindValue,即后端列名,同时充当草稿的 key */
key: string;
label: string;
/** BindOption.Type:决定抽屉里渲染哪种控件 */
type: string;
operator: string;
value: string;
value1: string;
values: string[];
choices: AdminFilterChoice[];
}
/** 列表数据源:admin-list 只认这个函数形状,具体 service 由页面闭包进去 */
export type AdminLoader<T = AdminRow> = (
query: AdminQuery,
) => Promise<AdminLoadResult<T>>;
/** 选择页回传给调用方的载荷 */
export interface AdminPickPayload<T = AdminRow> {
/** 调用方指定的通道名,一个页面可以有多个选择通道 */
channel: string;
rows: T[];
}
/**
* 详情页编辑控件的种类。
* 同 AdminFilterKind:用 const 对象 + keyof,不写字面量联合,
* 免得 Prettier 折行与 ESLint operator-linebreak 对撞。
* - digit / number:uni 的 input type,digit 不带小数点,适合 Community / UserGrade
* - date:走原生 <picker mode="date">,产出 yyyy-MM-dd
*/
export const ADMIN_FORM_KIND = {
TEXT: "text",
PASSWORD: "password",
NUMBER: "number",
DIGIT: "digit",
TEXTAREA: "textarea",
SWITCH: "switch",
DATE: "date",
} as const;
type FormKinds = typeof ADMIN_FORM_KIND;
export type AdminFormFieldKind = FormKinds[keyof FormKinds];
/** 详情页的一个字段 */
export interface AdminFormField {
label: string;
/** 实体上的属性名,编辑时读写 model[prop] */
prop: string;
/** 编辑控件种类,缺省 text */
type?: AdminFormFieldKind;
placeholder?: string;
required?: boolean;
/** 主键一类"编辑态也不许改"的字段:控件保持可见但锁死 */
lock?: boolean;
/** 只读模式下的显示换算;不给则直接读 model[prop](date 为真时按日期格式化) */
display?: (model: AdminRow) => string;
/** 只读模式按日期显示:true 取 yyyy-MM-dd,"datetime" 带时分秒 */
date?: boolean | "datetime";
maxlength?: number;
}
/** 详情页的一张卡片(旧 ft-card 的等价物) */
export interface AdminFormSection {
title: string;
fields: AdminFormField[];
}
/**
* 详情页底部按钮的描述。
* 组件不做权限判定:页面按 canAmend/canDelete/canCreate 组好数组传进来,
* 公共组件因此不需要知道任何域的权限名。
*/
export interface AdminFormAction {
key: string;
label: string;
/** primary 蓝、error 红(作废/删除),缺省 primary */
tone?: "primary" | "error";
/** 需要二次确认:走 uni.showModal,H5 与 App 都有原生观感 */
confirm?: boolean;
/** 只在某一态显示;缺省 both */
when?: "view" | "edit";
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 后端菜单 -> 本地页面的白名单映射
*
* 为什么必须有这张表:旧 WebVue 用后端返回的 PageUrl(形如
* `/src/views/security/UserEnquiry.vue`)配合 import.meta.glob **动态注册路由**,
* 而 uni-app 的页面必须在 pages.json 里静态登记,运行期造不出新页面。
* 所以菜单数据只能决定「哪个入口露出来、叫什么名、排第几」,
* 落地地址一律由这张白名单给出——白名单里没有的功能直接跳过,
* 绝不退化成"按 PageUrl 猜路径"(猜错就是 404,且会把没移植的功能暴露成坏入口)。
*/
import type { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplayData";
import type { PageName } from "@/router/routes";
/**
* 可在菜单里点击落地的功能名 -> 本项目页面名。
* key 必须逐字等于后端 security_function.function_name(实测取值见 src/md/admin-pages.md)。
* 只登记"入口型"功能(*Enquiry):*Operation 是从列表进详情页,不是菜单入口,
* 它们的权限名照样登记进 PAGE_PERMISSIONS,只是不在这里出现。
*/
export const MENU_PAGE_MAP: Record<string, PageName> = {
UserEnquiry: "userEnquiry",
// 角色查询既能从菜单进,也被 user-operation 当选择器跳转(同一页面)
ActorEnquiry: "actorEnquiry",
};
/** u-icon 的图标名必须来自 uview-pro 的内置图标集,否则渲染成空白 */
const MENU_ICON_MAP: Record<string, string> = {
wenjiansousuo: "search",
picture: "photo",
foldervideo: "play-circle",
foldermp3: "volume-up",
playmp3: "volume-up",
"mp3-play": "volume-up",
"rizhi-2": "file-text",
"shuazi-1": "reload",
yonghu1: "account",
fenzu: "tags",
dgroup: "grid",
anjianguanli: "lock",
dbank: "rmb",
zhanghuanquan: "integral",
};
/** 后端 icon 为空或映射不到时的兜底图标(实测 37 条功能里有 7 条 Icon 为空) */
export const DEFAULT_MENU_ICON = "grid";
/** 菜单图标:后端存的是旧项目 iconfont 的类名后缀,逐个翻译到 u-icon 命名 */
export function menuIcon(icon: string | undefined): string {
const key = String(icon ?? "").trim();
return MENU_ICON_MAP[key] ?? DEFAULT_MENU_ICON;
}
/** 首页入口项(已过滤、已排序、已带落地页名) */
export interface AdminMenuEntry {
/** 后端功能名,同时是排查权限时的关键字 */
function: string;
/** 落地页面名,交给 pageUrl() 拼地址 */
page: PageName;
/** 权限名(security_function.permission_name),按钮级判定用它 */
permission: string;
title: string;
icon: string;
orderby: number;
}
/** 中英双语取名:英文态优先 FunctionEngName,缺失时回落中文,两者都空则用功能名 */
export function menuTitle(
row: Pick<
SecurityFunctionDisplayData,
"FunctionName" | "FunctionLocalName" | "FunctionEngName"
>,
english: boolean,
): string {
const local = String(row.FunctionLocalName ?? "").trim();
const eng = String(row.FunctionEngName ?? "").trim();
if (english) {
return eng || local || row.FunctionName;
}
return local || eng || row.FunctionName;
}
/**
* 后端菜单行 -> 首页入口列表。
* 三道过滤:白名单认不出的跳过、PageName 没在 PAGE_PATHS 登记的跳过(由调用方判定,
* 见 composables/useAdminMenu)、同名功能去重(保留 orderby 更靠前的)。
* @param english 是否英文态标题
*/
export function mapMenuEntries(
rows: SecurityFunctionDisplayData[],
english: boolean,
): AdminMenuEntry[] {
const picked = new Map<string, AdminMenuEntry>();
for (const row of rows ?? []) {
const page = MENU_PAGE_MAP[row.FunctionName];
if (!page) {
continue;
}
const entry: AdminMenuEntry = {
function: row.FunctionName,
page,
permission: row.PermissionName ?? "",
title: menuTitle(row, english),
icon: menuIcon(row.Icon),
orderby: Number(row.Orderby ?? 0) || 0,
};
const exists = picked.get(entry.function);
if (!exists || entry.orderby < exists.orderby) {
picked.set(entry.function, entry);
}
}
const list = [...picked.values()];
list.sort(compareEntry);
return list;
}
/** 排序:先按后端 Orderby(实测种子全是 0,此时等于按登记顺序稳定排列),再按功能名 */
function compareEntry(a: AdminMenuEntry, b: AdminMenuEntry): number {
if (a.orderby !== b.orderby) {
return a.orderby - b.orderby;
}
return a.function.localeCompare(b.function);
}
@@ -0,0 +1,313 @@
<script setup lang="ts">
/**
* 管理端筛选抽屉:一条 BindOption 一行控件,点"确定"才把条件交回上层。
*
* 为什么不用 v-model 直接改上层数组:
* 抽屉里改的是 local(打开时从 items 拷一份),取消就整份丢掉。
* 旧 SearchCriteria 是改一下立即查询,手机上极易误触发请求。
*
* 为什么日期不用 uview 组件:
* uview-pro 0.7.1 没有 u-datetime-picker(只有 u-calendar / u-picker),
* 而原生 <picker mode="date|time"> 在 H5、App 两端都能用且零依赖,
* 所以这里直接用它,值形态由 @/admin/admin-date 补齐成后端要的字符串。
*/
import type { PropType } from "vue";
import type { AdminFilterDraftItem } from "@/admin/admin-types";
import { useLocale } from "uview-pro";
import { ref, watch } from "vue";
import { datePart } from "@/admin/admin-date";
import {
ADMIN_FILTER_QUICK_DAYS,
clearDraftItem,
draftKind,
isDraftFilled,
isDraftValueChecked,
isRangeDraft,
setDraftQuickRange,
toggleDraftValue,
} from "@/admin/admin-filter";
const props = defineProps({
/** 抽屉是否展开:靠它触发"重新取快照",不用 v-if 挂载(u-popup 自己要做进出动画) */
show: { type: Boolean, default: false },
/** 打开时的初始内容,来自 useAdminList().draft */
items: {
type: Array as PropType<AdminFilterDraftItem[]>,
default: () => [],
},
});
const emit = defineEmits<{
(e: "apply", items: AdminFilterDraftItem[]): void;
(e: "clearAll"): void;
(e: "close"): void;
}>();
const { t } = useLocale();
const local = ref<AdminFilterDraftItem[]>([]);
/** 深拷一条:values 是数组,共享引用会让"取消"也污染上层 */
function cloneItem(item: AdminFilterDraftItem): AdminFilterDraftItem {
return {
...item,
values: [...item.values],
choices: item.choices.map(choice => ({ ...choice })),
};
}
watch(
() => props.show,
(visible: boolean) => {
if (visible) {
local.value = props.items.map(cloneItem);
}
},
{ immediate: true },
);
function onDateChange(
item: AdminFilterDraftItem,
slot: "value" | "value1",
event: any,
): void {
// 原生 picker 产出的就是 yyyy-MM-dd,直接存字符串;
// DateTime 型要补成当天的起止秒,那一步在 applyDraft 里做(判定层集中,模板不管口径)
item[slot] = String(event?.detail?.value ?? "");
}
/** 布尔型筛选项:草稿里用 "true" / "" 表示,u-switch 只认布尔 */
function boolValue(item: AdminFilterDraftItem): boolean {
return item.value === "true";
}
function setBoolValue(item: AdminFilterDraftItem, checked: boolean): void {
item.value = checked ? "true" : "";
}
function isActive(item: AdminFilterDraftItem, choice: string): boolean {
if (draftKind(item) === "multiple") {
return isDraftValueChecked(item, choice);
}
return item.value === choice;
}
function pick(item: AdminFilterDraftItem, choice: string): void {
if (draftKind(item) === "multiple") {
toggleDraftValue(item, choice);
return;
}
// 单选再点一次取消:手机上没有"清除"按钮可点,只能靠再点一次
item.value = item.value === choice ? "" : choice;
}
function confirm(): void {
emit("apply", local.value);
}
</script>
<template>
<u-popup :model-value="props.show" mode="bottom" length="86%" border-radius="20" :safe-area-inset-bottom="true"
:closeable="true" close-icon-pos="top-right" @close="emit('close')">
<view class="drawer">
<view class="drawer-title">
<text>
{{ t("AdminPage.filterTitle") }}
</text>
</view>
<scroll-view class="drawer-body" scroll-y :show-scrollbar="false">
<view v-for="item in local" :key="item.key" class="field">
<view class="field-head">
<text class="field-label">
{{ item.label }}
</text>
<text v-if="isDraftFilled(item)" class="field-clear" @click="clearDraftItem(item)">
{{ t("AdminPage.clearBut") }}
</text>
</view>
<!-- 文本 / 数字 -->
<view v-if="draftKind(item) === 'text' || draftKind(item) === 'number'" class="line">
<u-input v-model="item.value" :type="draftKind(item) === 'number' ? 'number' : 'text'"
:placeholder="item.label" :border="true" clearable />
<template v-if="isRangeDraft(item)">
<text class="line-sep">
~
</text>
<u-input v-model="item.value1" type="number" :placeholder="t('AdminPage.endPlaceholder')"
:border="true" />
</template>
</view>
<!-- 日期 / 日期时间:原生 picker 的取值是字符串,不需要再格式化 -->
<view v-else-if="draftKind(item) === 'date' || draftKind(item) === 'datetime'" class="dates">
<view class="line">
<picker mode="date" :value="datePart(item.value)"
@change="onDateChange(item, 'value', $event)">
<view class="picker-box">
<text :class="{ 'is-empty': !item.value }">
{{ item.value || t("AdminPage.startPlaceholder") }}
</text>
</view>
</picker>
<template v-if="isRangeDraft(item)">
<text class="line-sep">
~
</text>
<picker mode="date" :value="datePart(item.value1)"
@change="onDateChange(item, 'value1', $event)">
<view class="picker-box">
<text :class="{ 'is-empty': !item.value1 }">
{{ item.value1 || t("AdminPage.endPlaceholder") }}
</text>
</view>
</picker>
</template>
</view>
<view v-if="isRangeDraft(item)" class="quick">
<view v-for="days in ADMIN_FILTER_QUICK_DAYS" :key="days" class="choice"
@click="setDraftQuickRange(item, days)">
<text>
{{ t("AdminPage.quickDays", { days }) }}
</text>
</view>
</view>
</view>
<!-- 布尔型 -->
<view v-else-if="draftKind(item) === 'switch'" class="line">
<u-switch :model-value="boolValue(item)" @change="setBoolValue(item, $event)" />
</view>
<!-- 候选项:单选与多选都用 chip,避免 u-radio/u-checkbox 的 name-value 歧义 -->
<view v-else class="choices">
<view v-for="choice in item.choices" :key="choice.value" class="choice"
:class="{ 'is-active': isActive(item, choice.value) }" @click="pick(item, choice.value)">
<text>
{{ choice.label }}
</text>
</view>
<view v-if="!item.choices.length" class="line">
<u-input v-model="item.value" :placeholder="item.label" :border="true" clearable />
</view>
</view>
</view>
</scroll-view>
<view class="drawer-foot">
<u-button shape="circle" :text="t('AdminPage.clearAllBut')" @click="emit('clearAll')" />
<u-button shape="circle" :text="t('AdminPage.cancelBut')" @click="emit('close')" />
<u-button type="primary" shape="circle" :text="t('AdminPage.confirmBut')" @click="confirm" />
</view>
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.drawer {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 28rpx;
background-color: #ffffff;
box-sizing: border-box;
}
.drawer-title {
padding: 32rpx 0 12rpx;
font-size: 32rpx;
font-weight: 600;
color: $u-main-color;
text-align: center;
}
.drawer-body {
flex: 1;
min-height: 0;
}
.field {
padding: 20rpx 0 8rpx;
border-bottom: 1rpx solid $u-border-color;
}
.field-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 14rpx;
}
.field-label {
font-size: 26rpx;
font-weight: 500;
color: $u-main-color;
}
.field-clear {
font-size: 24rpx;
color: $u-type-primary;
}
.line {
display: flex;
flex-direction: row;
align-items: center;
}
.line-sep {
margin: 0 12rpx;
color: $u-tips-color;
}
.dates {
padding-bottom: 8rpx;
}
.picker-box {
min-width: 240rpx;
padding: 14rpx 20rpx;
font-size: 26rpx;
color: $u-main-color;
background-color: $u-bg-color;
border-radius: 12rpx;
.is-empty {
color: $u-tips-color;
}
}
.quick,
.choices {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 12rpx;
}
.choice {
padding: 12rpx 28rpx;
margin: 8rpx 14rpx 8rpx 0;
font-size: 24rpx;
color: $u-content-color;
background-color: $u-bg-color;
border: 1rpx solid transparent;
border-radius: 999rpx;
&.is-active {
color: $u-type-primary;
background-color: $u-type-primary-light;
border-color: $u-type-primary;
}
}
.drawer-foot {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 24rpx 0 16rpx;
gap: 20rpx;
}
</style>
@@ -0,0 +1,324 @@
<script setup lang="ts">
/**
* 管理端详情页模板:浅色英雄卡 + iOS 分组行 + 明细插槽 + 动作条
*
* 对应旧 WebVue 的 ft-page / ft-hero / ft-card / ft-field 一套 CSS 结构
* (GroupOperation、UserOperation 这些页面本来就已经是单列卡片流,
* 所以详情页属于"翻译"而不是"重写",机械部分抽到这里)。
*
* 视觉走 iOS/微信「设置」式:分组卡片内一行一个字段,标签靠左、值靠右,
* 行间细线分隔;空值统一显示"暂无"而不是刺眼的"-"。
*
* 组件不判权限:按钮由页面按 canAmend/canDelete/canCreate 组好 actions 数组传进来。
* 这样公共组件不需要知道任何一个域的权限名,换域移植时零改动。
*/
import type { PropType } from "vue";
import type {
AdminFormAction,
AdminFormField,
AdminFormSection,
AdminRow,
} from "@/admin/admin-types";
import { useLocale } from "uview-pro";
import { computed } from "vue";
import { EMPTY_CELL_TEXT } from "@/admin/admin-column";
import { datePart, formatDate } from "@/admin/admin-date";
const props = defineProps({
navTitle: { type: String, default: "" },
showNavBack: { type: Boolean, default: true },
/** 顶部卡的主标题(旧 ft-hero-name) */
heroTitle: { type: String, default: "" },
/** 顶部卡的副信息(旧 ft-chip),如"账号 xxx" */
heroChips: { type: Array as PropType<string[]>, default: () => [] },
sections: { type: Array as PropType<AdminFormSection[]>, default: () => [] },
/** 页面持有的数据对象(SecurityUserData.SecurityUser 之类),字段直接读写它 */
model: { type: Object as PropType<AdminRow>, default: () => ({}) },
readonly: { type: Boolean, default: true },
actions: { type: Array as PropType<AdminFormAction[]>, default: () => [] },
});
const emit = defineEmits<{
(e: "back"): void;
(e: "action", action: AdminFormAction): void;
/** 字段改写走事件回给页面:组件不 mutate props.model(vue/no-mutating-props) */
(e: "field-change", prop: string, value: unknown): void;
}>();
const { t } = useLocale();
/** 只按当前态筛按钮:编辑态不该还能点"修改",只读态不该还能点"保存" */
function actionVisible(action: AdminFormAction): boolean {
if (!action.when) {
return true;
}
return action.when === (props.readonly ? "view" : "edit");
}
const visibleActions = computed(() => props.actions.filter(actionVisible));
function rawValue(field: AdminFormField): unknown {
return props.model?.[field.prop];
}
/** 只读态的显示文本:display 优先,其次日期格式化,最后原值;空值统一"未设置" */
function displayValue(field: AdminFormField): string {
const empty = t("AdminPage.notSetTip");
if (field.display) {
return field.display(props.model) || empty;
}
const raw = rawValue(field);
if (raw === null || raw === undefined || raw === "") {
return empty;
}
if (field.date) {
return formatDate(raw as Date, field.date === "datetime") || empty;
}
return String(raw);
}
/** 编辑态控件的值:统一成字符串,switch 例外(u-switch 只吃布尔) */
function inputValue(field: AdminFormField): string {
const raw = rawValue(field);
if (raw === null || raw === undefined) {
return "";
}
return String(raw);
}
function switchValue(field: AdminFormField): boolean {
const raw = rawValue(field);
return raw === true || raw === "Y" || raw === "true" || raw === 1;
}
function setInputValue(field: AdminFormField, value: unknown): void {
emit("field-change", field.prop, value);
}
function onDateChange(field: AdminFormField, event: any): void {
setInputValue(field, String(event?.detail?.value ?? ""));
}
function onTapAction(action: AdminFormAction): void {
if (action.confirm) {
uni.showModal({
title: t("AdminPage.confirmDeleteTitle"),
success: (res: any) => {
if (res.confirm) {
emit("action", action);
}
},
});
return;
}
emit("action", action);
}
</script>
<template>
<view class="admin-form">
<u-navbar v-if="navTitle" :title="navTitle" :is-back="showNavBack" :is-fixed="false" :immersive="false"
back-icon-name="arrow-leftward" @left-click="emit('back')" />
<scroll-view class="form-body" scroll-y :show-scrollbar="false">
<view class="hero">
<text class="hero-title">
{{ heroTitle || EMPTY_CELL_TEXT }}
</text>
<view v-if="heroChips.length" class="hero-chips">
<text v-for="chip in heroChips" :key="chip" class="hero-chip">
{{ chip }}
</text>
</view>
</view>
<view v-for="section in sections" :key="section.title" class="group">
<text class="group-title">
{{ section.title }}
</text>
<view class="group-card">
<view v-for="field in section.fields" :key="field.prop" class="row">
<view class="row-label">
<text>
{{ field.label }}
</text>
<text v-if="field.required && !readonly" class="row-required">
*
</text>
</view>
<!-- 只读:值右对齐,与标签同一行(iOS 设置式) -->
<text v-if="readonly" class="row-value">
{{ displayValue(field) }}
</text>
<!-- 编辑:控件右对齐占位 -->
<view v-else class="row-control">
<u-switch v-if="field.type === 'switch'" :model-value="switchValue(field)"
:disabled="field.lock" @change="setInputValue(field, $event)" />
<u-textarea v-else-if="field.type === 'textarea'" :model-value="inputValue(field)"
:disabled="field.lock" :placeholder="field.placeholder || field.label"
:maxlength="field.maxlength ?? 200" @input="setInputValue(field, $event)" />
<picker v-else-if="field.type === 'date'" mode="date" :value="datePart(inputValue(field))"
:disabled="field.lock" @change="onDateChange(field, $event)">
<view class="picker-box" :class="{ 'is-empty': !inputValue(field) }">
<text>
{{ inputValue(field) || field.placeholder || t('AdminPage.notSetTip') }}
</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</picker>
<!-- uview-pro 的 u-input border 是布尔,不是旧 uview 的 'surround' 字符串 -->
<u-input v-else :model-value="inputValue(field)" :type="field.type || 'text'"
:disabled="field.lock" :border="false" input-align="right"
:placeholder="field.placeholder || field.label" :maxlength="field.maxlength ?? -1"
@input="setInputValue(field, $event)" />
</view>
</view>
</view>
</view>
<!-- 明细区(角色、成员、附件等子表)由页面自己画 -->
<slot name="extra" :readonly="readonly" />
</scroll-view>
<view v-if="visibleActions.length" class="foot">
<u-button v-for="action in visibleActions" :key="action.key" shape="circle"
:type="action.tone === 'error' ? 'error' : 'primary'" :plain="action.tone !== 'error'"
:text="action.label" @click="onTapAction(action)" />
</view>
</view>
</template>
<style lang="scss" scoped>
.admin-form {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $u-bg-color;
}
.form-body {
flex: 1;
min-height: 0;
padding: 24rpx;
box-sizing: border-box;
}
.hero {
padding: 32rpx 28rpx;
margin-bottom: 28rpx;
color: #ffffff;
background: linear-gradient(135deg, $u-type-primary-dark, $u-type-primary);
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(5, 150, 105, 0.22);
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.hero-chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16rpx;
}
.hero-chip {
padding: 6rpx 18rpx;
margin: 8rpx 12rpx 0 0;
font-size: 22rpx;
background-color: rgba(255, 255, 255, 0.22);
border-radius: 20rpx;
}
.group {
margin-bottom: 28rpx;
}
.group-title {
padding: 0 12rpx 12rpx;
font-size: 24rpx;
color: $u-tips-color;
}
.group-card {
padding: 0 28rpx;
overflow: hidden;
background-color: #ffffff;
border-radius: 20rpx;
}
/* 一行一字段:标签靠左、值靠右,行间细线(最后一条不画线) */
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid $u-border-color;
&:last-child {
border-bottom: none;
}
}
.row-label {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
font-size: 28rpx;
color: $u-main-color;
}
.row-required {
margin-left: 6rpx;
color: $u-type-error;
}
.row-value {
max-width: 60%;
font-size: 28rpx;
color: $u-content-color;
text-align: right;
}
.row-control {
flex: 1;
min-width: 0;
text-align: right;
}
.picker-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
font-size: 28rpx;
color: $u-main-color;
&.is-empty {
color: $u-tips-color;
}
text {
margin-right: 8rpx;
}
}
.foot {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 20rpx 28rpx;
background-color: #ffffff;
border-top: 1rpx solid $u-border-color;
gap: 20rpx;
}
</style>
@@ -0,0 +1,432 @@
<script setup lang="ts">
/**
* 管理端列表页模板:z-paging + iOS/微信设置式卡片 + 筛选入口 + 长按多选
*
* 这是"移植模式不移植框架"的主要落点:旧 WebVue 的 SearchCard(876 行)
* 面向桌面多列网格,列配置、打印、排序表头、行选择一整套;
* 手机上真正要保留的只有三件事——分页取数、条件筛选、选中若干条做动作。
*
* 视觉走 iOS/微信「设置」式:白卡片 + 左固定标签列右跟值,
* 不再用「标签靠左 / 值靠右」两端对齐(那会在中间拉出一大片空白,很廉价)。
*
* 与 useAdminList 的分工:
* - useAdminList 管"条件与请求"(纯判定,node 下可测);
* - 本组件管"翻页、下拉、渲染、选中",不认识 service,也不认识权限;
* - 权限按钮一律由页面通过插槽给,组件内不做任何 canXxx 判定,
* 否则每个域都要改一遍这个公共组件。
*/
import type { PropType } from "vue";
import type {
AdminColumn,
AdminFilterDraftItem,
AdminLoadResult,
AdminRow,
} from "@/admin/admin-types";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import {
cellText,
pickTitleColumn,
restColumns,
rowKeyOf,
} from "@/admin/admin-column";
/** 列表取数:只给页码与每页条数,条件由上层闭包注入(见 useAdminList.query) */
type AdminListLoader = (page: number, pageSize: number) => Promise<AdminLoadResult>;
const props = defineProps({
/** 顶栏标题;空字符串表示页面自己画头部 */
navTitle: { type: String, default: "" },
showNavBack: { type: Boolean, default: true },
/** 卡片显示项(旧表格列的等价物) */
columns: { type: Array as PropType<AdminColumn[]>, required: true },
loader: { type: Function as PropType<AdminListLoader>, required: true },
pageSize: { type: Number, default: 20 },
/** 行唯一标识的字段名;留空取 columns[0].prop,再取不到就用下标 */
rowKey: { type: String, default: "" },
/** 卡片主标题字段名;留空取 columns[0] */
titleKey: { type: String, default: "" },
keyword: { type: String, default: "" },
filterCount: { type: Number, default: 0 },
filterVisible: { type: Boolean, default: false },
filterItems: {
type: Array as PropType<AdminFilterDraftItem[]>,
default: () => [],
},
/** 开启后长按卡片进入多选,底部出现操作条(操作条内容走 actions 插槽) */
selectable: { type: Boolean, default: false },
/** 页面每变一次就重新拉第一页(筛选生效、删除/保存后的刷新都走这里) */
searchToken: { type: Number, default: 0 },
/**
* 是否在挂载时自动查询。默认 false:
* 列表页的取数要排在登录守卫之后(见 useAuthGuard 的 onAuthorized),
* 否则未登录时会先打一发必然 401 的请求,错误弹窗和跳转一起出现。
*/
auto: { type: Boolean, default: false },
});
const emit = defineEmits<{
(e: "update:keyword", value: string): void;
(e: "search"): void;
(e: "open-filter"): void;
(e: "close-filter"): void;
(e: "apply-filter", items: AdminFilterDraftItem[]): void;
(e: "clear-filter"): void;
(e: "row-tap", row: AdminRow): void;
(e: "selection-change", rows: AdminRow[]): void;
}>();
const { t } = useLocale();
const paging = ref<any>(null);
const rows = ref<AdminRow[]>([]);
const selectedKeys = ref<string[]>([]);
const selectMode = ref(false);
const keywordModel = computed({
get: () => props.keyword,
set: (value: string) => emit("update:keyword", value),
});
const titleColumn = computed(() => pickTitleColumn(props.columns, props.titleKey));
const bodyColumns = computed(() => restColumns(props.columns, props.titleKey));
function keyOf(row: AdminRow): string {
const fallback = props.columns.length ? props.columns[0].prop : "";
return rowKeyOf(row, props.rowKey || fallback, rows.value.indexOf(row));
}
function isSelected(row: AdminRow): boolean {
return selectedKeys.value.includes(keyOf(row));
}
const selectedRows = computed(() =>
rows.value.filter(row => selectedKeys.value.includes(keyOf(row))),
);
const allSelected = computed(
() => rows.value.length > 0 && selectedRows.value.length === rows.value.length,
);
function clearSelection(): void {
selectedKeys.value = [];
selectMode.value = false;
emit("selection-change", []);
}
function toggleSelect(row: AdminRow): void {
const key = keyOf(row);
const next = selectedKeys.value.filter(item => item !== key);
// filter 后长度没变说明原本没选中,那就加上
if (next.length === selectedKeys.value.length) {
next.push(key);
}
selectedKeys.value = next;
emit("selection-change", rows.value.filter(item => next.includes(keyOf(item))));
}
function toggleSelectAll(): void {
if (allSelected.value) {
clearSelection();
return;
}
selectedKeys.value = rows.value.map(row => keyOf(row));
emit("selection-change", [...rows.value]);
}
function onLongPress(row: AdminRow): void {
if (!props.selectable) {
return;
}
selectMode.value = true;
toggleSelect(row);
}
async function onQuery(page: number, pageSize: number): Promise<void> {
try {
const result = await props.loader(page, pageSize);
if (page === 1) {
clearSelection();
}
paging.value?.completeByTotal(result.rows, result.total);
}
catch {
// 提示已由 http.interceptor 与全局错误边界统一弹过,这里只需让 z-paging 结束转圈
paging.value?.complete(false);
}
}
// 条件变了 / 数据变更后刷新:reload 会回到第 1 页并重新触发 @query
watch(
() => props.searchToken,
() => {
paging.value?.reload();
},
);
function refresh(): void {
paging.value?.reload();
}
defineExpose({ refresh, clearSelection, selectedRows });
</script>
<template>
<view class="admin-list">
<z-paging ref="paging" v-model="rows" :auto="auto" :default-page-size="pageSize" :refresher-enabled="true"
:loading-more-enabled="true" :empty-view-text="t('AdminPage.emptyText')" @query="onQuery">
<template #top>
<u-navbar v-if="navTitle" :title="navTitle" :is-back="showNavBack" :is-fixed="false" :immersive="false"
back-icon-name="arrow-leftward" />
<view class="toolbar">
<view class="toolbar-search">
<u-search v-model="keywordModel" :placeholder="t('AdminPage.searchPlaceholder')"
:show-action="false" :animation="false" @search="emit('search')" @clear="emit('search')" />
</view>
<view class="filter-entry" :class="{ 'is-active': filterCount }" @click="emit('open-filter')">
<u-icon name="list" size="40" />
<text v-if="filterCount" class="filter-badge">
{{ filterCount }}
</text>
</view>
</view>
<view v-if="$slots.toolbar" class="toolbar-extra">
<slot name="toolbar" />
</view>
</template>
<view v-for="row in rows" :key="keyOf(row)" class="card"
:class="{ 'is-picking': selectMode, 'is-selected': isSelected(row) }"
@click="selectMode ? toggleSelect(row) : emit('row-tap', row)" @longpress="onLongPress(row)">
<view v-if="selectMode" class="card-check" :class="{ 'is-on': isSelected(row) }">
<u-icon v-if="isSelected(row)" name="checkbox-mark" size="24" color="#ffffff" />
</view>
<view class="card-main">
<view class="card-head">
<text class="card-title">
{{ cellText(row, titleColumn as AdminColumn) }}
</text>
<u-icon v-if="!selectMode" name="arrow-right" size="24" color="#c4c8cf" />
</view>
<slot name="card" :row="row">
<view class="card-meta">
<view v-for="column in bodyColumns" :key="column.prop" class="meta-line">
<text class="meta-label">
{{ column.label }}
</text>
<text class="meta-value">
{{ cellText(row, column) }}
</text>
</view>
</view>
</slot>
</view>
</view>
<template #bottom>
<view v-if="selectMode && selectable" class="action-bar">
<view class="action-info" @click="toggleSelectAll">
<text class="action-toggle">
{{ allSelected ? t("AdminPage.unselectAllBut") : t("AdminPage.selectAllBut") }}
</text>
<text class="action-count">
{{ t("AdminPage.selectedText", { count: selectedRows.length }) }}
</text>
</view>
<slot name="actions" :rows="selectedRows" />
<view class="action-cancel" @click="clearSelection">
<text>
{{ t("AdminPage.cancelBut") }}
</text>
</view>
</view>
</template>
</z-paging>
<admin-filter-drawer :show="filterVisible" :items="filterItems"
@apply="(items: AdminFilterDraftItem[]) => emit('apply-filter', items)" @close="emit('close-filter')"
@clear-all="emit('clear-filter')" />
</view>
</template>
<style lang="scss" scoped>
.admin-list {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
.toolbar {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 24rpx;
background-color: #ffffff;
}
.toolbar-search {
flex: 1;
min-width: 0;
}
.filter-entry {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-left: 12rpx;
color: $u-tips-color;
background-color: $u-bg-color;
border-radius: 16rpx;
&.is-active {
color: $u-type-primary;
background-color: $u-type-primary-light;
}
}
.filter-badge {
position: absolute;
top: -6rpx;
right: -6rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 6rpx;
font-size: 20rpx;
line-height: 30rpx;
color: #ffffff;
text-align: center;
background-color: $u-type-error;
border-radius: 16rpx;
}
/* 页面自定义的次级工具(如"新增")走白底一行,与搜索栏区分 */
.toolbar-extra {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 4rpx 24rpx 16rpx;
background-color: #ffffff;
border-bottom: 1rpx solid $u-border-color;
}
.card {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 24rpx 28rpx;
margin: 20rpx 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(17, 24, 39, 0.05);
&.is-selected {
box-shadow: inset 0 0 0 2rpx $u-type-primary;
}
}
.card-check {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
margin-top: 4rpx;
margin-right: 18rpx;
border: 2rpx solid #d3d6db;
border-radius: 50%;
&.is-on {
background-color: $u-type-primary;
border-color: $u-type-primary;
}
}
.card-main {
flex: 1;
min-width: 0;
}
.card-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.card-title {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 30rpx;
font-weight: 600;
color: $u-main-color;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
margin-top: 10rpx;
}
.meta-line {
display: flex;
flex-direction: row;
align-items: baseline;
margin-top: 6rpx;
}
/* 左固定标签列 + 右跟值(左对齐),避免两端对齐拉出中间空白 */
.meta-label {
flex-shrink: 0;
width: 132rpx;
font-size: 24rpx;
color: $u-tips-color;
}
.meta-value {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 26rpx;
color: $u-content-color;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16rpx 28rpx;
background-color: #ffffff;
border-top: 1rpx solid $u-border-color;
}
.action-info {
display: flex;
flex-direction: column;
}
.action-toggle {
font-size: 26rpx;
color: $u-type-primary;
}
.action-count {
margin-top: 4rpx;
font-size: 22rpx;
color: $u-tips-color;
}
.action-cancel {
padding: 10rpx 24rpx;
font-size: 26rpx;
color: $u-content-color;
}
</style>
+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 };
}
@@ -7,6 +7,62 @@
"cancelBut": "Cancel",
"cultureLabel": "Language:"
},
"AdminPage": {
"filterBut": "Filter",
"filterTitle": "Filter Conditions",
"searchPlaceholder": "Search",
"confirmBut": "OK",
"cancelBut": "Cancel",
"clearBut": "Clear",
"clearAllBut": "Clear All",
"startPlaceholder": "From",
"endPlaceholder": "To",
"quickDays": "Last {days} days",
"totalText": "{total} record(s)",
"selectedText": "{count} selected",
"selectAllBut": "Select All",
"unselectAllBut": "Deselect",
"emptyText": "No matching records",
"viewBut": "View",
"amendBut": "Edit",
"createBut": "New",
"deleteBut": "Invalidate",
"saveBut": "Save",
"backBut": "Back",
"pickDoneBut": "Done",
"pickNoneTip": "Please select a record",
"notExistTip": "Record not found",
"notSetTip": "Not set",
"savedTip": "Saved",
"deletedTip": "Records invalidated",
"confirmDeleteTitle": "Invalidate the selected records?",
"noPermissionText": "No accessible admin function",
"menuLoading": "Loading menu…",
"menuRetryBut": "Reload",
"menuEmptyText": "The server returned no authorized function"
},
"UserPage": {
"title": "User Query",
"detailTitle": "User Detail",
"userName": "User Name",
"userLocalName": "Local Name",
"userEngName": "English Name",
"userMobile": "Mobile",
"userPassword": "Password",
"createdDatetime": "Created Date",
"account": "Account",
"unnamed": "No user name",
"actorTitle": "Roles",
"actorEnquiryTitle": "Role Query",
"actorName": "Role Name",
"actorLocalName": "Role Local Name",
"addActorBut": "Add Role",
"actorEmpty": "No role yet",
"unnamedActor": "Unnamed role",
"userNameRequired": "Please enter the user name",
"localNameRequired": "Please enter the local name",
"passwordTip": "Leave it empty to keep the password"
},
"ErrorPage": {
"reasonsTitle": "Possible reasons",
"fromLabel": "Failed address:",
@@ -7,6 +7,62 @@
"cancelBut": "取消",
"cultureLabel": "语言:"
},
"AdminPage": {
"filterBut": "筛选",
"filterTitle": "筛选条件",
"searchPlaceholder": "搜索",
"confirmBut": "确定",
"cancelBut": "取消",
"clearBut": "清空",
"clearAllBut": "全部清空",
"startPlaceholder": "起始",
"endPlaceholder": "截止",
"quickDays": "最近 {days} 天",
"totalText": "共 {total} 条",
"selectedText": "已选 {count} 条",
"selectAllBut": "全选",
"unselectAllBut": "取消选择",
"emptyText": "没有符合条件的数据",
"viewBut": "查看",
"amendBut": "修改",
"createBut": "新增",
"deleteBut": "无效",
"saveBut": "保存",
"backBut": "返回",
"pickDoneBut": "完成选择",
"pickNoneTip": "请选择一条记录",
"notExistTip": "记录不存在",
"notSetTip": "未设置",
"savedTip": "保存成功",
"deletedTip": "记录删除成功",
"confirmDeleteTitle": "确定要作废选中的记录吗?",
"noPermissionText": "没有可访问的管理功能",
"menuLoading": "菜单加载中…",
"menuRetryBut": "重新加载",
"menuEmptyText": "后端没有返回你有权限的功能"
},
"UserPage": {
"title": "用户查询",
"detailTitle": "用户详情",
"userName": "用户名称",
"userLocalName": "中文名称",
"userEngName": "英文名称",
"userMobile": "手机",
"userPassword": "登录密码",
"createdDatetime": "创建日期",
"account": "账号",
"unnamed": "未填写用户名称",
"actorTitle": "角色明细",
"actorEnquiryTitle": "角色查询",
"actorName": "角色名称",
"actorLocalName": "角色中文名称",
"addActorBut": "新增角色",
"actorEmpty": "暂无角色明细",
"unnamedActor": "未命名角色",
"userNameRequired": "请输入用户名称",
"localNameRequired": "请输入中文名称",
"passwordTip": "留空表示不修改密码"
},
"ErrorPage": {
"reasonsTitle": "可能的原因",
"fromLabel": "出错的地址:",
+132
View File
@@ -0,0 +1,132 @@
# 管理端页面层(security 域移动端移植)
适用范围:`src/components/admin-list|admin-form|admin-filter-drawer`、`src/composables/useAdminList|useAdminPick|useAdminMenu`、`src/admin/*`、`src/pages/security/*`、`src/pages/dashboard/dashboard.vue`,以及 `src/router/routes.ts` 里 security 域的 `PAGE_PATHS` / `PAGE_PERMISSIONS` 登记。
判定层(`can`/`canVisitPage`/`usePagePermission` 的语义)不在本文,见 `src/md/permission.md`;本文只讲"页面怎么把判定结果用起来"。
---
## 1. 移植策略:移植模式,不移植框架
旧 WebVue `src/views/security` 是桌面形态:`SearchCard`(近 900 行,多列网格 + 列配置 + 打印 + 排序表头 + 行选择)、`ft-page/ft-hero/ft-card/ft-field` 一套 CSS 布局、"选角色"用 `<dragModal>` 内嵌 Enquiry + `provide/inject` 双向通信。
手机上这三样都成立不了:屏幕放不下多列网格,页面之间没有 `provide/inject`(uni 每个页面是独立实例)。所以本层的取舍是——
- **保留** `bindOption` 数据模型(后端列名 / `Type` / `Operator` / `SelectOptions`)与查询语义(分页、条件、`orderby`),因为后端 `FindBySearchCriteria` 吃的就是这个形状;
- **砍掉** 多列网格、列配置、打印、排序表头——手机没有对应交互;
- **重写** 列表为卡片流、详情为单列卡、选择为"真跳一页再回传"。
复用分三层,边界清楚,换域移植(Group/Function/Dms/Publish…)时机械部分零改动:
| 层 | 文件 | 认识什么 | 不认识什么 |
| --- | --- | --- | --- |
| 纯函数 | `admin/admin-filter.ts`、`admin-date.ts`、`admin-column.ts`、`admin-types.ts` | SearchFilter / BindOption / 日期口径 | vue、uni、service |
| composable | `useAdminList`、`useAdminPick`、`useAdminMenu` | 上面那层 + vue 响应式 | z-paging、service、权限名 |
| 组件 | `admin-list`、`admin-form`、`admin-filter-drawer` | z-paging + uview-pro | service、**权限** |
**组件不判权限**:按钮由页面按 `canCreate/canAmend/canDelete` 组好数组(详情)或用 `v-if`(列表工具条)传进来。这样公共组件不需要知道任何一个域的权限名。
---
## 2. 三个页面 + 入口
| 页面名 | 路径 | 旧对应 | 作用 |
| --- | --- | --- | --- |
| `userEnquiry` | `pages/security/user-enquiry` | `UserEnquiry.vue` | 用户列表:三条筛选(用户名/中文名/创建日期区间)、每页 20、按创建日期倒序、行点进详情、多选批量作废 |
| `userOperation` | `pages/security/user-operation` | `UserOperation.vue` | 用户详情:查看/新增/修改,`?id=&mode=`;角色明细在 `#extra` 插槽自绘 |
| `actorEnquiry` | `pages/security/actor-enquiry` | `ActorEnquiry.vue` | 角色查询,**双用途**(见 §3.3) |
| `dashboard` | `pages/dashboard/dashboard` | `layout` 菜单 | 入口九宫格,`FindMenuByUsername` 驱动(见 §5) |
列表页写法(`user-enquiry.vue` 为准):`reactive(bindOptions)` + 一个只碰具体 service 的 `fetchData(query)` 交给 `useAdminList`;解构出的 `filterCount/filterVisible/draft/searchToken` 是 ref/computed,模板自动 unwrap,方法仍走 `state.xxx`。取数排在 `useAuthGuard` 之后(`admin-list` 的 `auto` 默认 false,首屏手动 `listRef.refresh()` 一次)。
**为什么 `bindOptions` 必须 `reactive`**:`useAdminList.filters` 是 `computed`,源数组非响应式就永不失效,改了条件却仍是旧结果。
---
## 3. 详情页与"选角色"
### 3.1 admin-form 的形状
`sections: AdminFormSection[]`(每张卡 `title` + `fields: AdminFormField[]`),字段用 `type`(text/password/number/digit/textarea/switch/date)、`lock`(主键编辑态也锁死)、`date`(只读态按日期格式化)、`required`。编辑控件的值改写通过 `@field-change(prop, value)` 回抛给页面,组件**不 mutate** `props.model`(`vue/no-mutating-props`)。
底部按钮是 `actions: AdminFormAction[]`:`when: "view" | "edit"` 按当前态过滤显示,`confirm: true` 走 `uni.showModal` 二次确认。
### 3.2 user-operation 的取数与保存
`onLoad` 读 `id`(=`UserName`)与 `mode`;`FindByKey` 取 `SecurityUserData`,取不到提示"记录不存在"。保存按有无 `CreatedBy` 决定 `Update`/`Insert`,成功后用后端回写的档案刷新、回填主键、退回只读。密码编辑的是 `SecurityUserDisplayData.OrgUserPassword`(留空即不改),只在编辑态出现。
### 3.3 选择页回传协议(useAdminPick / useAdminPickTarget)
旧系统的"选角色"是 Operation 用 `<dragModal>` 内嵌 Enquiry + `provide/inject`。移动端改成**真跳一页再回传**:
- 调用方 `useAdminPick<T>(channel).open({ page, params }, onPicked)`——先退订再订阅,`onUnmounted` 兜底清理;
- 目标页(`actor-enquiry`)`useAdminPickTarget()` 在 `onLoad` 里 `readOptions(options)` 存下 `channel`/`multiple`,确定时 `deliver(rows)`(`uni.$emit('admin:pick:'+channel, rows)` + `navigateBack`);
- `actor-enquiry` 因此**同一个列表两种用法**:菜单进入(无 `channel`)是普通查询;被带 `?channel=&multiple=1` 打开就是选择器,单选点行即回传、多选底部"完成"批量回传。
**协议参数一律不做 `encodeURIComponent`**:H5 侧 uni/vue-router 拼 URL 用 `encodeURI`,预编码会把 `%` 再编成 `%25`。`channel` 与 `params` 的值只允许字母/数字/下划线/连字符/点/斜杠。
> 命名订正:这个页面就叫 `actor-enquiry`(旧系统本是 `ActorEnquiry.vue`),早期草稿里的 `actor-pick` 是造错的概念——"选择"只是它带 `channel` 打开时的一种模式,不是另一张页面。
---
## 4. 权限登记与命名(改这条最容易踩坑)
`PAGE_PERMISSIONS` 登记(`routes.ts`):
| 页面 | permission_name | 来源说明 |
| --- | --- | --- |
| `userEnquiry` | `UserEnquiry` | 功能名与权限名**同名** |
| `userOperation` | `UserOperation` | 功能名与权限名**同名** |
| `actorEnquiry` | `SecurityActor` | 功能名是 `ActorEnquiry`,但权限名是**实体名** |
**security 域命名不统一,逐字照抄,别"顺手归一"**:多数功能(如 ActorEnquiry)的 `permission_name` 是实体名(`SecurityActor`/`SecurityGroup`),而 `UserEnquiry`/`UserOperation` 的 `permission_name` 与功能名本身同名。写错等于该页永远进不去。
反查方法:
- 菜单渲染用 `POST /SecurityUser/FindMenuByUsername`——**按当前用户授权过滤**,`UserId` 为空直接返回 `null`;
- 校对权限名用 `POST /SecurityFunction/FindMenus`——`[AllowAnonymous]` 的**全量表**(实测 37 行),只读它的 `permission_name` 列来核对登记,**绝不用它做菜单兜底**(会把没授权的功能也列出来)。
---
## 5. 入口菜单(useAdminMenu + menu-map 白名单)
数据链路三层过滤,缺一不可:
```
FindMenuByUsername(后端按用户过滤)
-> mapMenuEntries(menu-map.ts 白名单:只认已移植的功能)
-> canVisitPage(本地再判一次路由权限)
-> 剩下的才是能点的入口
```
- `menu-map.ts` 的 `MENU_PAGE_MAP` 把后端功能名映射到本地页名(现登记 `UserEnquiry`/`ActorEnquiry`),`MENU_ICON_MAP` 给图标(键含特殊字符故全部加引号,守 `quote-props: consistent`);没进白名单的功能不显示入口。
- **菜单缓存按账号**(模块级 `cached`/`cachedFor`):一次登录会话里首页反复 `onShow` 不重复打接口;换登录人或退出(`clearCache`)必须重取,否则会把上一个人的入口给下一个人。
- `canVisitPage` 再判一次的理由:菜单接口有缓存、档案可能比菜单新,入口点下去才 403 是最难排查的那种 bug。
---
## 6. 本批已知边界
- **只做了 User 域 + 入口菜单**。Group/Function/Dms/Publish 等域未移植;它们在 `menu-map` 白名单里没有映射,所以后端即便授权也不会显示入口——这是有意的,不是漏判。
- `actor-enquiry` 从菜单进入时"点行看详情"尚未接(本批它只作查询/回传),详情依赖后续 Actor 域。
- `PAGE_PERMISSIONS` 这三个权限名后端存在,但**当前账号是否被授予 `ViewIndc=Y` 尚未端到端验证**——入口可能点出 403。要验证就在真机上点,或查 `security_group_permission` 是否含对应授权。
---
## 7. 环境与代码约定(务必遵守,改一次撞一次)
- **i18n 走 uview-pro 的 `useLocale()`,项目没装 vue-i18n**:`import { useI18n } from "vue-i18n"` 会 TS2307。正确写法 `import { useLocale } from "uview-pro"; const { t, currentLocale } = useLocale();`。`currentLocale` 是 ref(`{ name }` 形态),语言切换监听用 `watch(currentLocale, ...)`,没有 `locale`。
- **ESLint CLI 在本机崩溃**:`eslint-plugin-jsdoc` 在 `join(import.meta.dirname, ...)` 处因当前 Node 版本 `import.meta.dirname` 为 `undefined` 抛 `ERR_INVALID_ARG_TYPE`(需 Node ≥ 20.11)。所以 lint 门禁靠 **IDE 诊断**(`GetProblems`),类型门禁靠 `npx vue-tsc --noEmit`(权威,能解析 `@/`)。IDE 报的"找不到模块 `@/…`"是别名解析未就绪的**误报**,以 vue-tsc 裁决。
- **`.vue` 模板**:`indent: 2`;多属性标签一律 tag 名单独一行、每个属性各占一行、缩进 = 元素缩进 + 2、闭合 `>` / `/>` 单独一行;带属性或有内容的 `<text>`/`<view>` 其内容单独成行(`vue/singleline-html-element-content-newline`);属性顺序 `ref` → `v-model` → `:props`/静态属性 → `@events`(`vue/attributes-order`)。
- **脚本/`.ts` 约定**:Stroustrup(`}` 换行后再 `catch`/`else`/`finally`);`arrow-parens: as-needed`(单参数无类型注解时不加括号);`quote-props: consistent`;`<script setup>` 内不 `export`;perfectionist 导入排序——type 块按路径字母序,value 块"包(`@dcloudio` → `uview-pro` → `vue`)先、后 alias 字母序"(`@/admin/*` < `@/composables` < `@/data/*` < `@/permission/*` < `@/policy/*` < `@/router/*` < `@/service/*`),同模块路径不重复 import。
- **uview-pro 组件契约**(0.6.x):`u-input` 的 `border` 是**布尔**,不是旧 uview 的 `'surround'` 字符串;`u-switch` 只吃布尔且只发 `change`;`u-popup` 的 `length`/`border-radius` 吃字符串。
- **`u-popup` 只认 `modelValue`,不消费 `show`**:其内部只 `watch(() => props.modelValue)` 调 `open()/close()`,`show` 仅在 types 里声明无人用。所以 `admin-filter-drawer` 必须 `:model-value="props.show"` 绑定,写 `:show` 抽屉永远弹不出来(表现为点了没反应、DOM 里只有一个注释占位节点)。图标名同理:uview-pro 没有 `filter` 图标(会渲染成空白方块),筛选入口用已存在的 `list`。
- **视觉语言**:管理页统一走 iOS/微信「设置」式,沿用登录页的翠绿主色(`green` 主题,primary `#059669`)。列表卡片 = 加粗主标题左上 + 右箭头 + 「左固定标签列(132rpx)+ 紧跟其左对齐的值」(不用两端对齐,避免中间拉大空白);详情 = 渐变英雄卡 + 分组白卡内「标签左 / 值右」带细线的行;空值统一显示 `AdminPage.notSetTip`("未设置")而非刺眼的 `-`(列表 `EMPTY_CELL_TEXT` 仍为 `-`,纯逻辑层不引 i18n);次级动作(新增)走靠右的翠绿实心胶囊。
## 8. 校验清单
改完这一层,按顺序跑(全部已在本批通过):
```
npx vue-tsc --noEmit # 类型 + 模板表达式,EXIT=0
node + esbuild 跑 permission-kernel.spec # 内核判定 59 断言(见 permission.md §7)
npm run build:h5 ; npm run build:app # 两端构建 DONE
```
lint(属性顺序 / 缩进 / singleline-content)当前只能靠 IDE `GetProblems` 逐个文件确认——CLI 不可用时,新写的每个 `.vue` 落盘后都单独查一遍。
+7 -8
View File
@@ -301,19 +301,18 @@ Get-ChildItem 'dist\build\mp-weixin' -Recurse -File -Include *.js | Select-Strin
## 8. 已知缺口
- **入口可见性这一层还没做(菜单/首页九宫格)**。权限的可见性分两层,本层只实现了第二层:
- **入口可见性(菜单/首页九宫格)已实现**——这一层不再是缺口。权限可见性分两层,两层现在都落地了:
| 层 | 管什么 | 数据源 | 现状 |
| --- | --- | --- | --- |
| ① 入口可见性 | 首页列不列得出这个入口 | `POST /SecurityUser/FindMenuByUsername`(后端按用户权限过滤) | **未实现**:`dashboard.vue` 是空白占位页 |
| ② 页面可进入性 | 手输地址/深链跳过去能不能进 | `PAGE_PERMISSIONS` + `canVisitPage()` | 判定层就绪,登记表为空 |
| ① 入口可见性 | 首页列不列得出这个入口 | `POST /SecurityUser/FindMenuByUsername`(后端按用户权限过滤) | **已实现**:`dashboard.vue` + `useAdminMenu` + `admin/menu-map.ts` 白名单,详见 `src/md/admin-pages.md` §5 |
| ② 页面可进入性 | 手输地址/深链跳过去能不能进 | `PAGE_PERMISSIONS` + `canVisitPage()` | 判定层就绪,security 域三页已登记(见下条) |
旧 WebVue 主要靠 ①:看不见的入口自然不会点,②只是补"手敲 URL"的漏洞。①的后端链路(`SecurityUserService.cs:232-248`)是 `用户名 -> security_user_actor -> security_actor_group -> security_group_permission -> security_permission_function_map -> security_function`,只返回本人被授权的功能,`UserId` 为空直接返回 null。
两个接口都已随 service 层移植进来且**零调用者**:`securityUserService.FindMenuByUsername()`(返回 `SecurityFunctionResultData`,含 `SecurityFunctionDisplayData[]` 的 `FunctionLocalName/FunctionEngName/Icon/Orderby/PageUrl/PermissionName` 与 `SecurityFunctionMaps` 层级,正是旧 `layout/index.vue` 的 `initMenu()` 消费的形态)和 `securityFunctionService.FindMenus()`(`[AllowAnonymous]`、**不区分用户的全量表**,旧项目只用来全量注册路由,别拿它做过滤)。
所以这一条缺口的真实含义是:等做首页入口时调 `FindMenuByUsername` 渲染,并用返回的 `PermissionName` 顺手校对 `PAGE_PERMISSIONS` 的登记;`stores/tabbar.ts` 的默认列表目前也是空的,等同一个数据源。
①的后端链路(`SecurityUserService.cs:232-248`)是 `用户名 -> security_user_actor -> security_actor_group -> security_group_permission -> security_permission_function_map -> security_function`,只返回本人被授权的功能,`UserId` 为空直接返回 null。`securityUserService.FindMenuByUsername()` 返回 `SecurityFunctionResultData`(含 `SecurityFunctionDisplayData[]` 的 `FunctionLocalName/FunctionEngName/Icon/Orderby/PermissionName`),正是旧 `layout/index.vue` 的 `initMenu()` 消费的形态;`useAdminMenu` 拿它渲染,再过 `menu-map` 白名单与 `canVisitPage` 两道本地过滤。`securityFunctionService.FindMenus()`(`[AllowAnonymous]`、**不区分用户的全量表**)仍只用来全量核对 `permission_name`,别拿它做菜单兜底。
遗留:`stores/tabbar.ts` 的默认列表仍是空的(tabbar 与本批九宫格是两套东西,暂未统一数据源)。
- **数据级(dependent)判定尚无真实页面在用**:旧项目所有调用点都是单参形式,口子为 `Submit`/`Approval` 与将来的目录级授权保留。
- **`PAGE_PERMISSIONS` 目前是空表**:现在只有 login / dashboard / 三个错误页,除 dashboard 有意不绑(§3.1)之外,其余页面还没迁进来。空表意味着路由级 403 实际上还没被任何页面触发,只能靠请求层的 HTTP 403。
- **403 页的"返回首页"在首页本身被拦时会原地弹回一次**:`page403.vue` 的返回按钮走 `reLaunch(pageUrl("dashboard"))`,而 reLaunch 同样过 `nav-guard`;若 dashboard 恰好被绑了拿不到的权限,这次跳转会被再拦一次、弹回 403(实测是一次性弹回,不是死循环,因为 403 页自身在 `AUTH_FREE_PAGES` 里)。等真有页面登记权限时,这里应该改成"按 `PAGE_PERMISSIONS` 找第一个可达页"或直接隐藏该按钮。
- **`PAGE_PERMISSIONS` 登记仍很稀疏**:security 域已登记 `userEnquiry=UserEnquiry`、`userOperation=UserOperation`、`actorEnquiry=SecurityActor` 三条(命名不统一、别"顺手归一"的坑见 `src/md/admin-pages.md` §4);其余旧 WebVue 域(Group/Function/Dms/Publish…)尚未迁进来。已登记页面会真正触发路由级 403,未登记页面仍只靠请求层 HTTP 403 兜底。
- **403 页的"返回首页"在首页本身被拦时会原地弹回一次**:`page403.vue` 的返回按钮走 `reLaunch(pageUrl("dashboard"))`,而 reLaunch 同样过 `nav-guard`;若 dashboard 恰好被绑了拿不到的权限,这次跳转会被再拦一次、弹回 403(实测是一次性弹回,不是死循环,因为 403 页自身在 `AUTH_FREE_PAGES` 里)。dashboard 目前有意不绑(§3.1),所以这条还触发不到;等哪天给 dashboard 也登记了权限,这里就该改成"按 `PAGE_PERMISSIONS` 找第一个可达页"或直接隐藏该按钮。
- **前端判定滞后于后端**:后端每次请求实时查库,前端读登录时的快照。管理员改了授权后,用户重新登录前看到的仍是旧按钮状态(可能点了才 403)。这是"权限随令牌下发"这个既有模型的代价,不是本层引入的。
- **`SecurityUserPermissionInfo.UserGroups` 未参与判定**:后端也不用它做短路(只用于 join 出授权行),前端同样只用 `Permissions` / `PermissionDependents`。
- **孤儿授权数据**:种子库里 `security_group_permission` 有 `abc` 组以及 `acc`/`PhotoPublish`/`AudioPublish`/`VideoPublish` 等权限名,而 `security_group` / `security_permission` 里查不到它们。判定层对此的表现是"多几行永远匹配不上的索引项",无害;但登记权限名时**不能以授权表的并集为准**,要以 `security_permission` 为准,否则会照抄到这些历史残留。
+11
View File
@@ -17,6 +17,17 @@
},
{
"path": "pages/error/page404"
},
// 管理端 security 域(旧 WebVue views/security 的第一批移植):
// user-enquiry 列表 / user-operation 详情(?id=&mode=view|create|amend)/ actor-enquiry 角色查询(兼作选择器)
{
"path": "pages/security/user-enquiry"
},
{
"path": "pages/security/user-operation"
},
{
"path": "pages/security/actor-enquiry"
}
],
// 全局配置
+109 -15
View File
@@ -1,33 +1,127 @@
<script setup lang="ts">
// 登录后的落地页:当前为空白占位,后续由旧 WebVue 项目的
// 菜单(/SecurityFunction/FindMenus)+ 权限守卫驱动真实内容
import { useAuthGuard } from "@/composables";
/**
* 登录后的落地页:后端按用户下发的功能菜单驱动入口网格
*
* 数据链路(详见 composables/useAdminMenu.ts):
* /SecurityUser/FindMenuByUsername(按用户授权)-> menu-map.ts 白名单 -> canVisitPage 复核。
* 三层过滤后剩下的才是能点的入口,绝不用 FindMenus 全量表兜底(会列出未授权功能)。
*/
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";
// 冷启动深链 / 分享链接直达本页时不经过任何导航 API,拦截器拦不到,故页面级再复核一次
useAuthGuard();
const { t } = useLocale();
const menu = useAdminMenu();
/** 可见入口: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();
});
</script>
<template>
<app-page :nav-title="WebSitePolicy.Title">
<app-page :nav-title="WebSitePolicy.Title" :show-nav-back="false" :hide-nav="true">
<view class="dashboard">
<view class="dashboard__placeholder">
{{ WebSitePolicy.Title }}
<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>
</template>
<style lang="scss" scoped>
.dashboard {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
display: flex;
flex-direction: column;
min-height: 60vh;
padding: 20rpx;
}
.dashboard__placeholder {
font-size: 32rpx;
color: $u-tips-color;
.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>
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* 角色查询(ActorEnquiry):旧 WebVue views/security/ActorEnquiry.vue 的移动端等价物
*
* 它有两种打开方式,同一套列表:
* 1. 从菜单进(无 channel):普通查询页,点行看详情(本批暂未接详情,仅回传/返回);
* 2. 被 user-operation 当"角色选择器"跳进来(带 ?channel=&multiple=1):
* 选完 uni.$emit 回传再 navigateBack(见 useAdminPick / useAdminPickTarget 协议)。
*
* 旧系统里这个选择能力是 Operation 用 <dragModal> 内嵌 Enquiry + provide/inject 实现的;
* 移动端页面之间没有 provide/inject,只能真跳一页再回传,所以这个页面本身就是 Enquiry。
*/
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 } from "vue";
import { fillCriteria, makeFilterOption } from "@/admin/admin-filter";
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
import { SecurityActorEntity } from "@/data/auto/securityActorEntity";
import { SecurityActorSearchCriteria } from "@/data/auto/securityActorSearchCriteria";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import securityActorService from "@/service/security/securityActorService";
const { t } = useLocale();
const pickTarget = useAdminPickTarget();
const bindOptions = reactive([
makeFilterOption(
SecurityActorEntity.DB_ACTOR_LOCAL_NAME,
t("UserPage.actorLocalName"),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
]);
async function fetchData(query: AdminQuery) {
const criteria = new SecurityActorSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await securityActorService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({ bindOptions, loader: fetchData });
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("UserPage.actorLocalName"),
prop: SecurityActorEntity.ACTOR_LOCAL_NAME,
},
{ label: t("UserPage.actorName"), prop: SecurityActorEntity.ACTOR_NAME },
]);
const keyword = ref("");
const listRef = ref<any>(null);
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === SecurityActorEntity.DB_ACTOR_LOCAL_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
// 多选态交给勾选,不在这里回传;单选态点一行即选中
if (pickTarget.multiple.value) {
return;
}
pickTarget.deliverOne(row);
}
function onConfirm(rows: AdminRow[]): void {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
pickTarget.deliver(rows);
}
onLoad((options) => {
pickTarget.readOptions(options);
});
let booted = false;
useAuthGuard(() => {
if (booted) {
return;
}
booted = true;
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('UserPage.actorEnquiryTitle')"
:columns="columns" :loader="state.query" :row-key="SecurityActorEntity.ACTOR_NAME"
title-key="ActorLocalName" :selectable="pickTarget.multiple.value" :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">
<template #actions="{ rows }">
<u-button type="primary" shape="circle" size="mini" :text="t('AdminPage.pickDoneBut')"
@click="onConfirm(rows)" />
</template>
</admin-list>
</view>
</template>
<style lang="scss" scoped>
.page {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
</style>
@@ -0,0 +1,208 @@
<script setup lang="ts">
/**
* 用户查询(列表页):旧 WebVue views/security/UserEnquiry.vue 的移动端等价物
*
* 保留的东西:三条筛选项(用户名/中文名/创建日期区间)、每页 20 条、
* 按创建日期倒序、行点击进详情、多选批量"无效"(旧页面也是循环 Delete,不是 Deletes)。
* 丢掉的东西:多列网格、列配置、打印——手机上没有对应交互。
*
* 页面只组数据与路由,列表/筛选/分页的机制全在 admin-list + useAdminList 里。
*/
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 } from "@/admin/admin-filter";
import { useAdminList, useAuthGuard } from "@/composables";
import { SecurityUserEntity } from "@/data/auto/securityUserEntity";
import { SecurityUserSearchCriteria } from "@/data/auto/securityUserSearchCriteria";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { usePagePermission } from "@/permission/permission-context";
import { pageUrl } from "@/router/routes";
import securityUserService from "@/service/security/securityUserService";
const { t, currentLocale } = useLocale();
const perm = usePagePermission("userEnquiry");
/** 列名 -> i18n key:抽屉标签、卡片行标签都靠它,切换语言时就地刷新而不重建条件 */
const LABEL_KEYS: Record<string, string> = {
[SecurityUserEntity.DB_USER_NAME]: "UserPage.userName",
[SecurityUserEntity.DB_USER_LOCAL_NAME]: "UserPage.userLocalName",
[SecurityUserEntity.DB_CREATED_DATETIME]: "UserPage.createdDatetime",
};
// reactive 是必须的:useAdminList 的 filters 是 computed,源数组非响应式就永远不会失效,
// 改了条件却仍是旧筛选结果是这一层最容易踩的坑
const bindOptions = reactive([
makeFilterOption(
SecurityUserEntity.DB_USER_NAME,
t(LABEL_KEYS[SecurityUserEntity.DB_USER_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
SecurityUserEntity.DB_USER_LOCAL_NAME,
t(LABEL_KEYS[SecurityUserEntity.DB_USER_LOCAL_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
SecurityUserEntity.DB_CREATED_DATETIME,
t(LABEL_KEYS[SecurityUserEntity.DB_CREATED_DATETIME]),
SearchFilterTypePolicy.DATE,
SearchFilterOperatorPolicy.DATE_BETWEEN,
),
]);
/** 取数:criteria 每页重建(与旧实现一致),只在这里碰具体 service */
async function fetchData(query: AdminQuery) {
const criteria = new SecurityUserSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await securityUserService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({
bindOptions,
loader: fetchData,
orderby: SecurityUserEntity.DB_CREATED_DATETIME,
orderbytag: "desc",
});
// 解构出来的是 ref/computed,交给模板自动 .value;方法仍走 state.xxx
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("UserPage.userLocalName"),
prop: SecurityUserEntity.USER_LOCAL_NAME,
},
{ label: t("UserPage.userName"), prop: SecurityUserEntity.USER_NAME },
{ label: t("UserPage.userMobile"), prop: SecurityUserEntity.USER_MOBILE },
{
label: t("UserPage.createdDatetime"),
prop: SecurityUserEntity.CREATED_DATETIME,
date: "datetime",
},
]);
const keyword = ref("");
const listRef = ref<any>(null);
/** 顶部搜索框:把关键字并进"用户名包含"这一条条件,复用同一套筛选链路 */
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === SecurityUserEntity.DB_USER_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
// 与旧页面一致:id 用 UserName,mode 决定详情页是只读还是编辑
// 不做 encodeURIComponent:见 composables/useAdminPick 的说明(H5 二次编码坑)
uni.navigateTo({
url: `${pageUrl("userOperation")}?id=${row.UserName}&mode=view`,
});
}
function onCreate(): void {
uni.navigateTo({ url: `${pageUrl("userOperation")}?mode=create` });
}
/** 批量作废:旧 UserEnquiry 也是逐条 Delete,Delete 语义最贴近后端 */
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 securityUserService.Delete(String(row.UserName));
}
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);
}
}
});
// 取数排在登录守卫之后:admin-list 的 auto 默认 false,首屏由这里手动触发一次
let booted = false;
useAuthGuard(() => {
if (booted) {
return;
}
booted = true;
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('UserPage.title')" :show-nav-back="false"
:columns="columns" :loader="state.query" :row-key="SecurityUserEntity.USER_NAME" title-key="UserLocalName"
:selectable="Boolean(perm.canDelete.value)" :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">
<template #toolbar>
<view v-if="perm.canCreate.value" class="create-entry" @click="onCreate">
<u-icon name="plus" size="24" color="#ffffff" />
<text class="create-text">
{{ t("AdminPage.createBut") }}
</text>
</view>
</template>
<template #actions="{ rows }">
<u-button v-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;
padding: 12rpx 28rpx;
color: #ffffff;
background-color: $u-type-primary;
border-radius: 999rpx;
box-shadow: 0 6rpx 16rpx rgba(5, 150, 105, 0.24);
}
.create-text {
margin-left: 8rpx;
font-size: 26rpx;
font-weight: 500;
}
</style>
@@ -0,0 +1,338 @@
<script setup lang="ts">
/**
* 用户详情(查看 / 新增 / 修改):旧 WebVue views/security/UserOperation.vue 的移动端等价物
*
* 逐条对齐旧逻辑:
* - onLoad 读 id(=UserName)与 mode(view|create|amend);
* - FindByKey 取 SecurityUserData,取不到提示"记录不存在";
* - 保存按 CreatedBy 有无决定 Update / Insert,成功后把主键回填、退回只读;
* - 密码编辑的是 SecurityUserDisplayData.OrgUserPassword(留空即不改);
* - 角色明细按 ActorName 去重合并,删除即从数组里剔除。
*
* 旧页面的"选角色"是弹窗内嵌 Enquiry + provide/inject;这里改成真跳 actor-enquiry 页
* 再回传(见 composables/useAdminPick),所以角色区放在 admin-form 的 #extra 插槽里自绘。
*/
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 { useAdminPick, useAuthGuard } from "@/composables";
import { SecurityUserActorViewEntity } from "@/data/auto/securityUserActorViewEntity";
import { SecurityUserData } from "@/data/auto/securityUserData";
import { usePagePermission } from "@/permission/permission-context";
import securityUserService from "@/service/security/securityUserService";
const { t } = useLocale();
const perm = usePagePermission("userOperation");
const data = ref<SecurityUserData>(new SecurityUserData());
const user = computed(() => data.value.SecurityUser);
const readonly = ref(true);
const routeId = ref("");
/** 新增态判定:没有 CreatedBy 就说明是还没落库的新记录 */
const isNew = computed(() => !String(user.value.CreatedBy ?? "").trim());
const heroTitle = computed(
() => String(user.value.UserLocalName || user.value.UserName || ""),
);
const heroChips = computed(() => {
const chips: string[] = [];
if (user.value.UserName) {
chips.push(`${t("UserPage.account")}: ${user.value.UserName}`);
}
if (user.value.UserMobile) {
chips.push(`${t("UserPage.userMobile")}: ${user.value.UserMobile}`);
}
return chips;
});
const sections = computed<AdminFormSection[]>(() => {
const fields: AdminFormField[] = [
{
label: t("UserPage.userName"),
prop: "UserName",
required: true,
// 已有记录时主键锁死:与旧页面"编辑态 UserName 只读"一致
lock: routeId.value !== "",
},
{ label: t("UserPage.userLocalName"), prop: "UserLocalName", required: true },
{ label: t("UserPage.userEngName"), prop: "UserEngName" },
{ label: t("UserPage.userMobile"), prop: "UserMobile" },
{ label: t("UserPage.createdDatetime"), prop: "CreatedDatetime", date: "datetime", lock: true },
];
// 密码只在编辑态出现:查看态没必要显示(即便是掩码)
if (!readonly.value) {
fields.push({
label: t("UserPage.userPassword"),
prop: "OrgUserPassword",
type: "password",
placeholder: t("UserPage.passwordTip"),
});
}
return [{ title: t("UserPage.detailTitle"), fields }];
});
const actions = computed<AdminFormAction[]>(() => {
const list: AdminFormAction[] = [];
if (perm.canAmend.value) {
list.push({ key: "amend", label: t("AdminPage.amendBut"), when: "view" });
}
if (perm.canDelete.value) {
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 securityUserService.FindByKey(routeId.value);
if (!result?.SecurityUser) {
uni.showToast({ title: t("AdminPage.notExistTip"), icon: "none" });
return;
}
data.value = result;
}
/** 字段改写:admin-form 不直接改 props.model,统一回抛到这里 */
function onFieldChange(prop: string, value: unknown): void {
(user.value as Record<string, any>)[prop] = value;
}
async function onSave(): Promise<void> {
if (!String(user.value.UserName ?? "").trim()) {
uni.showToast({ title: t("UserPage.userNameRequired"), icon: "none" });
return;
}
if (!String(user.value.UserLocalName ?? "").trim()) {
uni.showToast({ title: t("UserPage.localNameRequired"), icon: "none" });
return;
}
const isUpdate = !isNew.value;
const result = await (isUpdate
? securityUserService.Update(data.value)
: securityUserService.Insert(data.value));
// 后端回写后的档案才是准的(新记录拿到了 CreatedBy)
if (result?.SecurityUser) {
data.value = result;
}
routeId.value = String(user.value.UserName ?? "");
readonly.value = true;
uni.showToast({ title: t("AdminPage.savedTip"), icon: "none" });
}
async function onDelete(): Promise<void> {
await securityUserService.Delete(routeId.value);
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
setTimeout(() => uni.navigateBack(), 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 (isNew.value || !routeId.value) {
uni.navigateBack();
return;
}
readonly.value = true;
load();
break;
default:
break;
}
}
function onBack(): void {
uni.navigateBack();
}
// 角色选择:跳 actor-enquiry(可多选),回传后按 ActorName 去重合并
const actorPicker = useAdminPick<SecurityUserActorViewEntity>("actor");
function onAddActor(): void {
actorPicker.open(
{ page: "actorEnquiry", params: { multiple: "1" } },
(rows) => {
const exists = new Set(
data.value.SecurityUserActors.map(item => item.ActorName),
);
for (const row of rows) {
const actorName = String(row.ActorName ?? "");
if (!actorName || exists.has(actorName)) {
continue;
}
const entity = new SecurityUserActorViewEntity();
entity.UserName = String(user.value.UserName ?? "");
entity.ActorName = actorName;
entity.ActorLocalName = String(row.ActorLocalName ?? "");
data.value.SecurityUserActors.push(entity);
exists.add(actorName);
}
},
);
}
function onDeleteActor(actorName: string): void {
data.value.SecurityUserActors = data.value.SecurityUserActors.filter(
item => item.ActorName !== actorName,
);
}
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('UserPage.detailTitle')" :show-nav-back="true" :hero-title="heroTitle"
:hero-chips="heroChips" :sections="sections" :model="user" :readonly="readonly" :actions="actions"
@back="onBack" @action="onAction" @field-change="onFieldChange">
<template #extra="{ readonly: ro }">
<view class="group">
<view class="group-head">
<text class="group-title">
{{ t("UserPage.actorTitle") }}
</text>
<view v-if="!ro && perm.canAmend.value" class="group-add" @click="onAddActor">
<u-icon name="plus" size="24" color="#059669" />
<text class="group-add-text">
{{ t("UserPage.addActorBut") }}
</text>
</view>
</view>
<view class="group-card">
<view v-if="!data.SecurityUserActors.length" class="group-empty">
<text>
{{ t("UserPage.actorEmpty") }}
</text>
</view>
<view v-for="actor in data.SecurityUserActors" :key="actor.ActorName" class="actor-row">
<view class="actor-text">
<text class="actor-name">
{{ actor.ActorLocalName || actor.ActorName || t("UserPage.unnamedActor") }}
</text>
<text v-if="actor.ActorLocalName" class="actor-sub">
{{ actor.ActorName }}
</text>
</view>
<u-icon v-if="!ro && perm.canAmend.value" name="trash" size="34" color="#c4c8cf"
@click="onDeleteActor(actor.ActorName)" />
</view>
</view>
</view>
</template>
</admin-form>
</template>
<style lang="scss" scoped>
/* 角色明细:与 admin-form 的分组卡同一套视觉(细标题 + 白卡 + 行间细线) */
.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-add {
display: flex;
flex-direction: row;
align-items: center;
}
.group-add-text {
margin-left: 4rpx;
font-size: 24rpx;
color: $u-type-primary;
}
.group-card {
padding: 0 28rpx;
overflow: hidden;
background-color: #ffffff;
border-radius: 20rpx;
}
.group-empty {
padding: 40rpx 0;
font-size: 24rpx;
color: $u-tips-color;
text-align: center;
}
.actor-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid $u-border-color;
&:last-child {
border-bottom: none;
}
}
.actor-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.actor-name {
font-size: 28rpx;
color: $u-main-color;
}
.actor-sub {
margin-top: 4rpx;
font-size: 22rpx;
color: $u-tips-color;
}
</style>
+15 -1
View File
@@ -15,6 +15,10 @@ export const PAGE_PATHS = {
error401: "pages/error/page401",
error403: "pages/error/page403",
error404: "pages/error/page404",
// 管理端(旧 WebVue 的 security 域):短地址按末段推导 => /user-enquiry 等
userEnquiry: "pages/security/user-enquiry",
userOperation: "pages/security/user-operation",
actorEnquiry: "pages/security/actor-enquiry",
} as const;
export type PageName = keyof typeof PAGE_PATHS;
@@ -49,7 +53,17 @@ export const AUTH_FREE_PAGES: PageName[] = [
* DmsFileEnquiry),写错等于该页永远进不去;登记流程与反查方法见 src/md/permission.md。
* 数组表示任一命中即放行。缺省(未登记)表示该页不做权限校验,登录校验照旧。
*/
export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {};
export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
// 三个权限名均取自运行中后端的 security_function.permission_name(POST
// /SecurityFunction/FindMenus 实测 37 行,详见 src/md/admin-pages.md §4)。
// 注意 security 域的命名不统一:多数功能 perm 是实体名(SecurityActor/SecurityGroup),
// 但 UserEnquiry、UserOperation 的 perm 与功能名同名,逐字照抄,不要"顺手归一"。
// actorEnquiry 既作菜单入口,也被 user-operation 当"角色选择器"跳转,功能名 ActorEnquiry
// 的 permission_name 是实体名 SecurityActor。
userEnquiry: "UserEnquiry",
userOperation: "UserOperation",
actorEnquiry: "SecurityActor",
};
/** 带前导斜杠的真实路径,供 uni.* API 之外的路径比较、日志使用 */
export function toRealPath(name: PageName): string {