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 = {}, ): Promise { return await materialApi.list(params); } /** 顶部统计卡片数据 */ export async function getMaterialStat(): Promise { return await requestClient.get('material/stat'); } /** * 单批拉取 OSS 对象;finished 为假时带着 next_marker 再调一次 * * 七牛要先查区域再列举再入库,默认 10s 必超时,这一口单独放到 10 分钟 */ export async function syncMaterialFromOss(data: { limit: number; marker: string; oss_config_id: number; prefix: string; }): Promise { return await requestClient.post( 'material/sync-from-oss', data, { timeout: 600_000 }, ); } /** 全量扫描业务表里的图片字段,回写 ref_count */ export async function scanMaterialReferences(): Promise { return await requestClient.post( 'material/scan-references', {}, ); } /** 回收建议列表:days 天内没被引用过的素材 */ export async function getMaterialUnusedList(params: { days: number; page: number; pageSize: number; }): Promise { return await requestClient.get('material/unused-list', { params, }); } /** 真删:连 OSS 上的对象一起删,调用方必须做二次确认 */ export async function reclaimMaterials( ids: number[], ): Promise { return await requestClient.post('material/reclaim', { ids, }); } /** 批量归档到文件夹,folder_id 传 0 表示移出文件夹 */ export async function moveMaterialsToFolder(data: { folder_id: number; ids: number[]; }) { return await requestClient.post('material/move-to-folder', data); } /** * 文件夹树 * * option 接口可能返回扁平数据(后端未建树),这里按 pid 兜一层, * 否则子文件夹会全部平铺在根上,层级信息白丢 */ export async function getFileFolderTree(): Promise { const list: FileFolderItem[] = (await fileFolderApi.option()) ?? []; if (list.length === 0 || list.some((item) => item.children)) { return list; } const nodes = new Map( 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 { 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 = { '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): boolean { return Number(item.type) === 0 || item.type === 'image'; }