20260811165111

This commit is contained in:
oneao committed 2026-08-11 16:51:11 +08:00
1 parent 5adbf59425
commit ce78653207
34 files changed
+3793 -366

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { nextIdApi } from "@/services/api";
/** 深拷贝任意 JSON 可序列化值。 */
export function cloneData<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
export interface RowDiff<T> {
inserts: T[];
updates: T[];
deletes: { b_id: string }[];
}
/**
* 比较当前草稿与原始快照,返回新增/修改/删除三份变更列表。
* 以 b_id 为唯一标识(字符串比较),行内容用 JSON 序列化比对。
*/
export function diffRows<T extends { b_id: string }>(current: T[], previous: T[]): RowDiff<T> {
const previousById = new Map(previous.map((item) => [String(item.b_id), item]));
const currentById = new Map(current.map((item) => [String(item.b_id), item]));
return {
inserts: current.filter((item) => !previousById.has(String(item.b_id))),
updates: current.filter((item) => {
const old = previousById.get(String(item.b_id));
return old && JSON.stringify(item) !== JSON.stringify(old);
}),
deletes: previous
.filter((item) => !currentById.has(String(item.b_id)))
.map((item) => ({ b_id: item.b_id })),
};
}
/**
* 生成临时 ID 与真实雪花 ID 的映射:
* 扫描所有行组中形如 "-123" 的临时负数 ID,批量向后端申请真实 ID 并就地替换。
* 返回 临时ID → 真实ID 的映射表(供外键 remap 使用)。
*/
export async function allocateTemporaryIds(
rowGroups: Record<string, unknown>[][],
): Promise<Map<string, string>> {
const temporaryIds = [
...new Set(
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ""))
.filter((id) => /^-\d+$/.test(id)),
),
];
if (!temporaryIds.length) return new Map();
const res = await nextIdApi(temporaryIds.length);
const ids = Array.isArray(res.data) ? res.data : [res.data];
const mapping = new Map<string, string>(
temporaryIds.map((id, index) => [id, String(ids[index])]),
);
rowGroups.flat().forEach((row) => {
const id = mapping.get(String(row?.b_id));
if (id) row.b_id = id;
});
return mapping;
}
/** 根据映射表,将 rows 中指定外键字段的临时 ID 替换为真实 ID。 */
export function remapForeignKeys(
rows: Record<string, unknown>[],
fields: string[],
mapping: Map<string, string>,
): void {
rows.forEach((row) => {
fields.forEach((field) => {
const value = mapping.get(String(row[field]));
if (value) row[field] = value;
});
});
}
/** 构造单个表的保存请求对象。 */
export function tableChange(
table: string,
changes: {
inserts?: unknown[];
updates?: unknown[];
deletes?: unknown[];
},
): import("@/services/api").SaveRequest {
return { table, key_field: "b_id", ...changes };
}
/** 判断某个保存请求是否包含任何变更。 */
export function hasChanges(request: import("@/services/api").SaveRequest): boolean {
return Boolean(request.inserts?.length || request.updates?.length || request.deletes?.length);
}
+156
View File
@@ -0,0 +1,156 @@
/** 校验模块编码:非空、长度 2-50、小写字母开头,仅含小写字母/数字/下划线。 */
export function validateModuleCode(code: unknown): string {
const value = String(code ?? "").trim();
if (!value) return "编码不能为空";
if (value.length < 2 || value.length > 50) return "编码长度需为 2-50 个字符";
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
return "编码须以小写字母开头,且只能包含小写字母、数字和下划线";
}
return "";
}
/** 从 i18n key 中提取命名空间(第一个点之前的部分)。 */
export function getI18nNamespace(key: unknown): string {
return String(key || "").split(".", 1)[0] || "common";
}
/** 校验 i18n key 是否合法。 */
export function isValidI18nKey(key: unknown): boolean {
return /^[A-Za-z][A-Za-z0-9_.:-]{1,149}$/.test(String(key || ""));
}
/** 为 item(字段/权限等)生成受管 i18n key。 */
export function buildModuleItemI18nKey(
prefix: string,
moduleCode: unknown,
itemCode: unknown,
): string | null {
const normalizedModuleCode = String(moduleCode || "").trim();
const normalizedItemCode = String(itemCode || "").trim();
return normalizedModuleCode && normalizedItemCode
? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}`
: null;
}
/** 为模块本身生成受管 i18n key(data 类型用 module 前缀,其余用 menu 前缀)。 */
export function buildModuleI18nKey(moduleType: unknown, moduleCode: unknown): string | null {
const normalizedModuleCode = String(moduleCode || "").trim();
if (!normalizedModuleCode) return null;
const prefix = moduleType === "data" ? "module" : "menu";
return `${prefix}.${normalizedModuleCode}`;
}
/**
* 同步子表行的受管 i18n key。返回是否有变更。
*/
export function syncModuleItemI18nKeys(
rows: Record<string, unknown>[] | undefined,
prefix: string,
moduleCode: unknown,
codeField: string,
): boolean {
let changed = false;
for (const row of rows || []) {
const key = buildModuleItemI18nKey(prefix, moduleCode, row?.[codeField]);
if (row.b_i18n === key) continue;
row.b_i18n = key;
changed = true;
}
return changed;
}
/** 将扁平 i18n 记录按 b_key 聚合成透视行(含各语言值及原始记录引用)。 */
export function groupI18nRows(
rows: { b_key: string; b_locale: string; b_value: unknown }[],
): Record<
string,
{ key: string; values: Record<string, unknown>; records: Record<string, unknown> }
> {
const groups: Record<
string,
{ key: string; values: Record<string, unknown>; records: Record<string, unknown> }
> = {};
rows.forEach((item) => {
const key = item.b_key;
groups[key] ||= { key, values: {}, records: {} };
groups[key].values[item.b_locale] = item.b_value;
groups[key].records[item.b_locale] = item;
});
return groups;
}
/**
* 将同步字段/权限生成的默认翻译条目并入透视行数组:
* - 已存在同 b_key 的行则补全其空语言值(不覆盖用户已填内容);
* - 否则新增一行;managedId 用于关联字段,使重命名字段时复用同一行。
* 返回新数组,不修改入参。
*/
export function mergeI18nDefaults(
rows: Record<string, unknown>[],
entries: { key: string; locale: string; value: unknown; managedId?: string }[],
): Record<string, unknown>[] {
const next = [...rows];
const byKey = new Map(next.map((r) => [String(r.b_key), r]));
const managedIds = new Set(
next
.map((r) => r._managedId)
.filter(Boolean)
.map(String),
);
for (const e of entries || []) {
if (!e.key) continue;
const existing = byKey.get(String(e.key));
if (existing) {
if (existing[e.locale] == null || existing[e.locale] === "") {
existing[e.locale] = e.value;
}
continue;
}
if (e.managedId && managedIds.has(String(e.managedId))) continue;
const row: Record<string, unknown> = {
_id: "i18n_" + Math.random().toString(36).slice(2, 10),
b_key: e.key,
_localeIds: {},
...(e.managedId ? { _managedId: String(e.managedId) } : {}),
...(e.managedId ? { _syncInjected: true } : {}),
[e.locale]: e.value,
};
next.push(row);
byKey.set(String(e.key), row);
if (e.managedId) managedIds.add(String(e.managedId));
}
return next;
}
/**
* 合并同 b_key 的重复透视行,保证一个键仅一行。
* 多行同键时保留各自非空的语言值,受管标记等取优先者。
* 返回新数组,不修改入参。
*/
export function consolidatePivotRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const byKey = new Map<string, Record<string, unknown>>();
const merged: Record<string, unknown>[] = [];
for (const row of rows) {
const key = String(row.b_key || "");
const hit = byKey.get(key);
if (hit) {
for (const [k, v] of Object.entries(row)) {
if (k === "_id" || k === "_localeIds") continue;
if ((hit[k] == null || hit[k] === "") && v != null) hit[k] = v;
}
Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {});
if (row._managedId && !hit._managedId) hit._managedId = row._managedId;
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected;
if (row._defaultValue != null && hit._defaultValue == null) {
hit._defaultValue = row._defaultValue;
}
if (row._sortType && !hit._sortType) hit._sortType = row._sortType;
} else {
byKey.set(key, row);
merged.push(row);
}
}
return merged;
}
+12
View File
@@ -0,0 +1,12 @@
// 生成本地临时 ID,用于尚未保存到后端的子表新增行。
// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。
let sequence = 0;
/**
* 生成会话内唯一的临时 ID(负数字符串)。
* @returns 形如 "-1"、"-2" 的临时 ID。
*/
export function createTempId(): string {
sequence += 1;
return String(-sequence);
}