ow
This commit is contained in:
1 parent
3c0d389fcd
commit
32f237c4bf
22 files changed
+3351
-24
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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` : "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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": "出错的地址:",
|
||||
|
||||
@@ -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` 落盘后都单独查一遍。
|
||||
@@ -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` 为准,否则会照抄到这些历史残留。
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
// 全局配置
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in new issue
Block a user