diff --git a/uniapp/youjiala/src/admin/admin-column.ts b/uniapp/youjiala/src/admin/admin-column.ts new file mode 100644 index 0000000..db738b2 --- /dev/null +++ b/uniapp/youjiala/src/admin/admin-column.ts @@ -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); +} diff --git a/uniapp/youjiala/src/admin/admin-date.ts b/uniapp/youjiala/src/admin/admin-date.ts new file mode 100644 index 0000000..7e27fb5 --- /dev/null +++ b/uniapp/youjiala/src/admin/admin-date.ts @@ -0,0 +1,98 @@ +/** + * 管理端的日期取值与显示:不依赖 dayjs,纯函数(可在 node 下直接跑 spec) + * + * 为什么自己写:旧前端的筛选条件用 dayjs 存值(BindOption.DayjsValue), + * 再在提交时 format;移动端选用的原生 `` 产出的是 + * **"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` : ""; +} diff --git a/uniapp/youjiala/src/admin/admin-filter.ts b/uniapp/youjiala/src/admin/admin-filter.ts new file mode 100644 index 0000000..9eb90e8 --- /dev/null +++ b/uniapp/youjiala/src/admin/admin-filter.ts @@ -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( + 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; +} diff --git a/uniapp/youjiala/src/admin/admin-types.ts b/uniapp/youjiala/src/admin/admin-types.ts new file mode 100644 index 0000000..028c1bc --- /dev/null +++ b/uniapp/youjiala/src/admin/admin-types.ts @@ -0,0 +1,159 @@ +/** + * 管理端模板的公共形状定义 + * + * 单列出来只为一个原因:admin-list / admin-form / useAdminList 三者要共享这些类型, + * 放进任何一个 .vue 里都会变成循环引用。 + */ +import type { SearchFilter } from "@/data/auto/searchFilter"; + +/** 后端返回的行对象(各域的 Entity 字段大小写不一,这里只能给宽松形状) */ +export type AdminRow = Record; + +/** + * 卡片上的一行显示项。 + * 取值顺序: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 { + 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 = ( + query: AdminQuery, +) => Promise>; + +/** 选择页回传给调用方的载荷 */ +export interface AdminPickPayload { + /** 调用方指定的通道名,一个页面可以有多个选择通道 */ + channel: string; + rows: T[]; +} + +/** + * 详情页编辑控件的种类。 + * 同 AdminFilterKind:用 const 对象 + keyof,不写字面量联合, + * 免得 Prettier 折行与 ESLint operator-linebreak 对撞。 + * - digit / number:uni 的 input type,digit 不带小数点,适合 Community / UserGrade + * - 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"; +} diff --git a/uniapp/youjiala/src/admin/menu-map.ts b/uniapp/youjiala/src/admin/menu-map.ts new file mode 100644 index 0000000..04e59f2 --- /dev/null +++ b/uniapp/youjiala/src/admin/menu-map.ts @@ -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 = { + UserEnquiry: "userEnquiry", + // 角色查询既能从菜单进,也被 user-operation 当选择器跳转(同一页面) + ActorEnquiry: "actorEnquiry", +}; + +/** u-icon 的图标名必须来自 uview-pro 的内置图标集,否则渲染成空白 */ +const MENU_ICON_MAP: Record = { + 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(); + 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); +} diff --git a/uniapp/youjiala/src/components/admin-filter-drawer/admin-filter-drawer.vue b/uniapp/youjiala/src/components/admin-filter-drawer/admin-filter-drawer.vue new file mode 100644 index 0000000..2a555fa --- /dev/null +++ b/uniapp/youjiala/src/components/admin-filter-drawer/admin-filter-drawer.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/uniapp/youjiala/src/components/admin-form/admin-form.vue b/uniapp/youjiala/src/components/admin-form/admin-form.vue new file mode 100644 index 0000000..a5df7a1 --- /dev/null +++ b/uniapp/youjiala/src/components/admin-form/admin-form.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/uniapp/youjiala/src/components/admin-list/admin-list.vue b/uniapp/youjiala/src/components/admin-list/admin-list.vue new file mode 100644 index 0000000..6b70c93 --- /dev/null +++ b/uniapp/youjiala/src/components/admin-list/admin-list.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/uniapp/youjiala/src/composables/index.ts b/uniapp/youjiala/src/composables/index.ts index 8beb140..cfbeaa6 100644 --- a/uniapp/youjiala/src/composables/index.ts +++ b/uniapp/youjiala/src/composables/index.ts @@ -1,2 +1,5 @@ +export * from "./useAdminList"; +export * from "./useAdminMenu"; +export * from "./useAdminPick"; export * from "./useAuthGuard"; export * from "./useLang"; diff --git a/uniapp/youjiala/src/composables/useAdminList.ts b/uniapp/youjiala/src/composables/useAdminList.ts new file mode 100644 index 0000000..5098b5c --- /dev/null +++ b/uniapp/youjiala/src/composables/useAdminList.ts @@ -0,0 +1,130 @@ +/** + * 管理端列表页的共用状态机:筛选条 + 分页取数 + 抽屉草稿 + * + * 它取代的是旧 WebVue 每个 Enquiry 页里重复三遍的东西: + * initBindOption()、onSubmit/onPagination/onReload 三件套、以及 + * SearchCard 内部维护的 pagingParams/result/isSearched。 + * 页面只需要提供两样东西:bindOptions(筛选项登记)和 loader(怎么取数)。 + * + * 分工边界(重要): + * - 本 composable 不认识 z-paging,也不认识 uni.*:它只把"一次查询"表达成 + * query(page, limit) -> Promise,翻页/下拉刷新/完成回调都归 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 { + 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; diff --git a/uniapp/youjiala/src/composables/useAdminMenu.ts b/uniapp/youjiala/src/composables/useAdminMenu.ts new file mode 100644 index 0000000..6712562 --- /dev/null +++ b/uniapp/youjiala/src/composables/useAdminMenu.ts @@ -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([]); +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 { + 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 }; +} diff --git a/uniapp/youjiala/src/composables/useAdminPick.ts b/uniapp/youjiala/src/composables/useAdminPick.ts new file mode 100644 index 0000000..414c492 --- /dev/null +++ b/uniapp/youjiala/src/composables/useAdminPick.ts @@ -0,0 +1,108 @@ +/** + * 管理端"选择页"回传协议 + * + * 旧 WebVue 的做法:Operation 页面里塞一个 ``,内嵌 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; +} + +function buildQuery(params: Record): 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(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): 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 }; +} diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json index 1574805..7297178 100644 --- a/uniapp/youjiala/src/locale/lang/en-US.json +++ b/uniapp/youjiala/src/locale/lang/en-US.json @@ -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:", diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json index 43a4a63..a7f3f72 100644 --- a/uniapp/youjiala/src/locale/lang/zh-CN.json +++ b/uniapp/youjiala/src/locale/lang/zh-CN.json @@ -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": "出错的地址:", diff --git a/uniapp/youjiala/src/md/admin-pages.md b/uniapp/youjiala/src/md/admin-pages.md new file mode 100644 index 0000000..aaeca49 --- /dev/null +++ b/uniapp/youjiala/src/md/admin-pages.md @@ -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 布局、"选角色"用 `` 内嵌 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 用 `` 内嵌 Enquiry + `provide/inject`。移动端改成**真跳一页再回传**: + +- 调用方 `useAdminPick(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、闭合 `>` / `/>` 单独一行;带属性或有内容的 ``/`` 其内容单独成行(`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`;` diff --git a/uniapp/youjiala/src/pages/security/actor-enquiry.vue b/uniapp/youjiala/src/pages/security/actor-enquiry.vue new file mode 100644 index 0000000..75fe2d8 --- /dev/null +++ b/uniapp/youjiala/src/pages/security/actor-enquiry.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/uniapp/youjiala/src/pages/security/user-enquiry.vue b/uniapp/youjiala/src/pages/security/user-enquiry.vue new file mode 100644 index 0000000..cdb5d54 --- /dev/null +++ b/uniapp/youjiala/src/pages/security/user-enquiry.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/uniapp/youjiala/src/pages/security/user-operation.vue b/uniapp/youjiala/src/pages/security/user-operation.vue new file mode 100644 index 0000000..7ab4b2a --- /dev/null +++ b/uniapp/youjiala/src/pages/security/user-operation.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/uniapp/youjiala/src/router/routes.ts b/uniapp/youjiala/src/router/routes.ts index 122fc97..67a11ca 100644 --- a/uniapp/youjiala/src/router/routes.ts +++ b/uniapp/youjiala/src/router/routes.ts @@ -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> = {}; +export const PAGE_PERMISSIONS: Partial> = { + // 三个权限名均取自运行中后端的 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 {