222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
/**
|
||
* 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。
|
||
* 版本:v1.1.0
|
||
*/
|
||
import { Message } from '@arco-design/web-vue';
|
||
import { onBeforeUnmount, reactive } from 'vue';
|
||
import { resourceApi } from '@/api/resource';
|
||
import {
|
||
staffRelationFilters,
|
||
type StaffRelationContext,
|
||
} from '@/api/resource-staff-relation';
|
||
import type { ResourceField } from '@/api/resources';
|
||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||
import { fieldRelationResource } from '@/api/resource-display';
|
||
import { relationDisplayName } from '../shared/resource-list-field-display';
|
||
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
|
||
|
||
export type ResourceRelationLoadOptions = {
|
||
filters?: Record<string, string>;
|
||
preserveIdentities?: string[];
|
||
};
|
||
|
||
export function useResourceRelations(
|
||
context: () => StaffRelationContext = () => ({}),
|
||
) {
|
||
const options = reactive<Record<string, ResourceRow[]>>({});
|
||
const loading = reactive<Record<string, boolean>>({});
|
||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||
const requestGuard = createRelationRequestVersionGuard();
|
||
|
||
/** 合并需要保留的当前选项,搜索和筛选时不让下拉框退回显示原始标识。 */
|
||
function mergePreserved(
|
||
resource: string,
|
||
rows: ResourceRow[],
|
||
identities: string[] = [],
|
||
) {
|
||
const merged = [...rows];
|
||
const seen = new Set(rows.map((row) => String(row.identity ?? '')));
|
||
for (const identity of identities.filter(Boolean)) {
|
||
if (seen.has(identity)) continue;
|
||
const current = (options[resource] ?? []).find(
|
||
(row) => String(row.identity ?? '') === identity,
|
||
);
|
||
if (current) {
|
||
merged.push(current);
|
||
seen.add(identity);
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
/** 加载一类关联资源,并丢弃已经过期的搜索或筛选响应。 */
|
||
async function load(
|
||
resource: string,
|
||
keyword = '',
|
||
request: ResourceRelationLoadOptions = {},
|
||
) {
|
||
const version = requestGuard.begin(resource);
|
||
loading[resource] = true;
|
||
try {
|
||
const filters: Record<string, string> = {
|
||
...(request.filters ?? {}),
|
||
...(keyword ? { keyword } : {}),
|
||
};
|
||
const rows = (
|
||
await resourceApi.list<ResourceRow>(resource, 1, 100, filters)
|
||
).list;
|
||
if (!requestGuard.isLatest(resource, version)) return;
|
||
options[resource] = mergePreserved(
|
||
resource,
|
||
rows,
|
||
request.preserveIdentities,
|
||
);
|
||
} catch (error) {
|
||
if (!requestGuard.isLatest(resource, version)) return;
|
||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||
} finally {
|
||
if (requestGuard.isLatest(resource, version)) {
|
||
loading[resource] = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 按字段的显式关系策略加载选项,工作人员字段不得依赖全局默认值。 */
|
||
function loadField(
|
||
field: ResourceField,
|
||
keyword = '',
|
||
request: ResourceRelationLoadOptions = {},
|
||
) {
|
||
if (!field.relation) return Promise.resolve();
|
||
return load(field.relation, keyword, {
|
||
...request,
|
||
filters: {
|
||
...(field.relationFilters ?? {}),
|
||
...staffRelationFilters(field, context()),
|
||
...(request.filters ?? {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */
|
||
async function ensure(resource: string, identity: string) {
|
||
if (!identity) return undefined;
|
||
const current = (options[resource] ?? []).find(
|
||
(row) => String(row.identity ?? '') === identity,
|
||
);
|
||
if (current) return current;
|
||
try {
|
||
const row = await resourceApi.detail<ResourceRow>(resource, identity);
|
||
const resolved = (options[resource] ?? []).find(
|
||
(item) => String(item.identity ?? '') === identity,
|
||
);
|
||
if (resolved) return resolved;
|
||
options[resource] = [...(options[resource] ?? []), row];
|
||
return row;
|
||
} catch (error) {
|
||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/** 预加载当前页面实际使用的全部关系字段。 */
|
||
async function preload(fields: ResourceField[]) {
|
||
const fieldsByResource = new Map<string, ResourceField>();
|
||
for (const field of fields) {
|
||
if (field.relation && !fieldsByResource.has(field.relation)) {
|
||
fieldsByResource.set(field.relation, field);
|
||
}
|
||
}
|
||
await Promise.all(
|
||
[...fieldsByResource.values()].map((field) => loadField(field)),
|
||
);
|
||
}
|
||
|
||
/** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */
|
||
async function ensureValues(fields: ResourceField[], values: ResourceRow) {
|
||
const requests = fields.flatMap((field) => {
|
||
// 服务端已在当前业务记录范围内返回名称时,不再重复补载关联用户。
|
||
if (relationDisplayName(field, values)) return [];
|
||
const resource = fieldRelationResource(field, values);
|
||
if (!resource) return [];
|
||
const value = values[field.key];
|
||
const identities = Array.isArray(value) ? value : [value];
|
||
return identities
|
||
.map((identity) => String(identity ?? ''))
|
||
.filter(Boolean)
|
||
.map((identity) => ensure(resource, identity));
|
||
});
|
||
await Promise.all(requests);
|
||
}
|
||
|
||
/** 对远程关系选项进行防抖搜索。 */
|
||
function search(
|
||
resource: string | undefined,
|
||
keyword: string,
|
||
request: ResourceRelationLoadOptions = {},
|
||
) {
|
||
if (!resource) return;
|
||
cancelSearch(resource);
|
||
timers.set(
|
||
resource,
|
||
setTimeout(() => {
|
||
timers.delete(resource);
|
||
void load(resource, keyword.trim(), request);
|
||
}, 250),
|
||
);
|
||
}
|
||
|
||
/** 按字段的显式关系策略执行防抖搜索。 */
|
||
function searchField(
|
||
field: ResourceField,
|
||
keyword: string,
|
||
request: ResourceRelationLoadOptions = {},
|
||
) {
|
||
if (!field.relation) return;
|
||
search(field.relation, keyword, {
|
||
...request,
|
||
filters: {
|
||
...(field.relationFilters ?? {}),
|
||
...staffRelationFilters(field, context()),
|
||
...(request.filters ?? {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 取消关联资源尚未触发的防抖搜索。 */
|
||
function cancelSearch(resource: string) {
|
||
const timer = timers.get(resource);
|
||
if (timer) clearTimeout(timer);
|
||
timers.delete(resource);
|
||
}
|
||
|
||
/** 清空依赖父级但当前尚无有效父级的候选项。 */
|
||
function clear(resource: string | undefined) {
|
||
if (!resource) return;
|
||
cancelSearch(resource);
|
||
requestGuard.begin(resource);
|
||
options[resource] = [];
|
||
loading[resource] = false;
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
for (const resource of timers.keys()) cancelSearch(resource);
|
||
});
|
||
|
||
return {
|
||
options,
|
||
loading,
|
||
load,
|
||
loadField,
|
||
ensure,
|
||
ensureValues,
|
||
preload,
|
||
search,
|
||
searchField,
|
||
cancelSearch,
|
||
clear,
|
||
};
|
||
}
|
||
|
||
export type ResourceRelations = ReturnType<typeof useResourceRelations>;
|