Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
229 lines
6.2 KiB
TypeScript
229 lines
6.2 KiB
TypeScript
import { requestClient } from '#/api/request';
|
||
import { createCrudApi } from '#/components/crud';
|
||
|
||
/** 素材记录,字段与后端 material 表一致 */
|
||
export interface MaterialItem {
|
||
created_at: string;
|
||
ext: string;
|
||
folder_id: number;
|
||
height: number;
|
||
id: number;
|
||
last_scan_at: string;
|
||
name: string;
|
||
oss_config_id: number;
|
||
path: string;
|
||
/** 被业务表引用的次数,0 表示可回收 */
|
||
ref_count: number;
|
||
size: number;
|
||
/** 后端存 0~6,兼容历史字符串 */
|
||
type: number | string;
|
||
url: string;
|
||
width: number;
|
||
}
|
||
|
||
export interface MaterialListResult {
|
||
items: MaterialItem[];
|
||
page: number;
|
||
page_count: number;
|
||
size: number;
|
||
total: number;
|
||
}
|
||
|
||
export interface MaterialStat {
|
||
total: number;
|
||
total_size: number;
|
||
types: { count: number; type: number | string }[];
|
||
unused: number;
|
||
unused_size: number;
|
||
}
|
||
|
||
export interface MaterialSyncResult {
|
||
finished: boolean;
|
||
inserted: number;
|
||
/** 本批从 OSS 列举到的对象数;为 0 且 finished 说明前缀或 Bucket 对不上 */
|
||
listed: number;
|
||
next_marker: string;
|
||
updated: number;
|
||
}
|
||
|
||
export interface MaterialScanResult {
|
||
referenced: number;
|
||
scanned: number;
|
||
unused: number;
|
||
}
|
||
|
||
/**
|
||
* 回收失败项:契约只冻结了 failed 是数组,元素形状没定,
|
||
* 字符串和对象两种都兜住,免得后端改一次前端就白屏
|
||
*/
|
||
export type MaterialReclaimFailure =
|
||
| string
|
||
| { id?: number; name?: string; path?: string; reason?: string };
|
||
|
||
export interface MaterialReclaimResult {
|
||
deleted: number;
|
||
failed: MaterialReclaimFailure[];
|
||
}
|
||
|
||
export interface FileFolderItem {
|
||
children?: FileFolderItem[];
|
||
id: number;
|
||
name: string;
|
||
pid: number;
|
||
}
|
||
|
||
export const materialApi = createCrudApi('material');
|
||
|
||
export const fileFolderApi = createCrudApi('file-folder');
|
||
|
||
/** 素材列表;folder_id / type / ext / unused 都是可选筛选项 */
|
||
export async function getMaterialList(
|
||
params: Record<string, any> = {},
|
||
): Promise<MaterialListResult> {
|
||
return await materialApi.list(params);
|
||
}
|
||
|
||
/** 顶部统计卡片数据 */
|
||
export async function getMaterialStat(): Promise<MaterialStat> {
|
||
return await requestClient.get<MaterialStat>('material/stat');
|
||
}
|
||
|
||
/**
|
||
* 单批拉取 OSS 对象;finished 为假时带着 next_marker 再调一次
|
||
*
|
||
* 七牛要先查区域再列举再入库,默认 10s 必超时,这一口单独放到 10 分钟
|
||
*/
|
||
export async function syncMaterialFromOss(data: {
|
||
limit: number;
|
||
marker: string;
|
||
oss_config_id: number;
|
||
prefix: string;
|
||
}): Promise<MaterialSyncResult> {
|
||
return await requestClient.post<MaterialSyncResult>(
|
||
'material/sync-from-oss',
|
||
data,
|
||
{ timeout: 600_000 },
|
||
);
|
||
}
|
||
|
||
/** 全量扫描业务表里的图片字段,回写 ref_count */
|
||
export async function scanMaterialReferences(): Promise<MaterialScanResult> {
|
||
return await requestClient.post<MaterialScanResult>(
|
||
'material/scan-references',
|
||
{},
|
||
);
|
||
}
|
||
|
||
/** 回收建议列表:days 天内没被引用过的素材 */
|
||
export async function getMaterialUnusedList(params: {
|
||
days: number;
|
||
page: number;
|
||
pageSize: number;
|
||
}): Promise<MaterialListResult> {
|
||
return await requestClient.get<MaterialListResult>('material/unused-list', {
|
||
params,
|
||
});
|
||
}
|
||
|
||
/** 真删:连 OSS 上的对象一起删,调用方必须做二次确认 */
|
||
export async function reclaimMaterials(
|
||
ids: number[],
|
||
): Promise<MaterialReclaimResult> {
|
||
return await requestClient.post<MaterialReclaimResult>('material/reclaim', {
|
||
ids,
|
||
});
|
||
}
|
||
|
||
/** 批量归档到文件夹,folder_id 传 0 表示移出文件夹 */
|
||
export async function moveMaterialsToFolder(data: {
|
||
folder_id: number;
|
||
ids: number[];
|
||
}) {
|
||
return await requestClient.post<any>('material/move-to-folder', data);
|
||
}
|
||
|
||
/**
|
||
* 文件夹树
|
||
*
|
||
* option 接口可能返回扁平数据(后端未建树),这里按 pid 兜一层,
|
||
* 否则子文件夹会全部平铺在根上,层级信息白丢
|
||
*/
|
||
export async function getFileFolderTree(): Promise<FileFolderItem[]> {
|
||
const list: FileFolderItem[] = (await fileFolderApi.option()) ?? [];
|
||
if (list.length === 0 || list.some((item) => item.children)) {
|
||
return list;
|
||
}
|
||
const nodes = new Map<number, FileFolderItem>(
|
||
list.map((item) => [item.id, { ...item, children: [] }]),
|
||
);
|
||
const roots: FileFolderItem[] = [];
|
||
for (const item of nodes.values()) {
|
||
const parent = nodes.get(item.pid);
|
||
if (parent) {
|
||
parent.children?.push(item);
|
||
} else {
|
||
roots.push(item);
|
||
}
|
||
}
|
||
return roots;
|
||
}
|
||
|
||
/** 表单选上级用:首位补虚拟顶级节点,pid=0 表示挂在最外层 */
|
||
export async function getFileFolderTreeWithRoot(): Promise<any[]> {
|
||
return [{ id: 0, name: '顶级文件夹' }, ...(await getFileFolderTree())];
|
||
}
|
||
|
||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
|
||
/**
|
||
* 字节转可读体积
|
||
*
|
||
* 统计卡片、素材卡片、回收列表都要展示体积,格式必须一致,
|
||
* 各处自己算会出现 1024KB 和 1MB 混着显示。
|
||
*/
|
||
export function formatSize(bytes?: null | number): string {
|
||
const value = Number(bytes ?? 0);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return '0 B';
|
||
}
|
||
const index = Math.min(
|
||
Math.floor(Math.log(value) / Math.log(1024)),
|
||
SIZE_UNITS.length - 1,
|
||
);
|
||
const scaled = value / 1024 ** index;
|
||
const text =
|
||
index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1);
|
||
return `${text} ${SIZE_UNITS[index]}`;
|
||
}
|
||
|
||
/** 与 FileModel::TYPE_* 对齐:0 图片 / 1 视频 / 2 音频 / 3 表格 / 4 压缩包 / 5 文档 / 6 其他 */
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
'0': '图片',
|
||
'1': '视频',
|
||
'2': '音频',
|
||
'3': '表格',
|
||
'4': '压缩包',
|
||
'5': '文档',
|
||
'6': '其他',
|
||
archive: '压缩包',
|
||
audio: '音频',
|
||
doc: '文档',
|
||
excel: '表格',
|
||
image: '图片',
|
||
other: '其他',
|
||
video: '视频',
|
||
};
|
||
|
||
/** 类型中文名;数字、字符串两种都认,避免拉取成功后卡片上写「未知」 */
|
||
export function materialTypeText(type?: number | string): string {
|
||
if (type === undefined || type === null || type === '') {
|
||
return '未知';
|
||
}
|
||
return TYPE_LABELS[String(type)] ?? String(type);
|
||
}
|
||
|
||
/** 只有图片能出缩略图与水印预览;后端 type=0 就是图片 */
|
||
export function isImageMaterial(item: Pick<MaterialItem, 'type'>): boolean {
|
||
return Number(item.type) === 0 || item.type === 'image';
|
||
}
|