/** * 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。 */ import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts'; import { INSTANCE_STATES } from '@contracts'; import type { FieldSpec } from './launch-params.ts'; export const SUMMARY_MAX = 80; /** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */ export function clip(text: string, max = SUMMARY_MAX): string { const one = text.replace(/\s+/g, ' ').trim(); return one.length > max ? `${one.slice(0, max - 1)}…` : one; } /** * 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明), * 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。 */ export function inputSummaryOf(data: Readonly>, specs: readonly FieldSpec[], fallback?: string): string { const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key)); for (const spec of specs) { if (spec.control !== 'textarea') continue; const value = data[spec.key]; if (typeof value === 'string' && value.trim() !== '') return clip(value); } let longest = ''; for (const [key, value] of Object.entries(data)) { if (fileKeys.has(key) || typeof value !== 'string') continue; if (value.trim().length > longest.trim().length) longest = value; } if (longest.trim() !== '') return clip(longest); return fallback === undefined ? '' : clip(fallback); } export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string }; /** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */ export function inputFilesOf(data: Readonly>, specs: readonly FieldSpec[]): InputFileRef[] { const out: InputFileRef[] = []; for (const spec of specs) { if (spec.format !== 'file') continue; const raw = data[spec.key]; const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw]; for (const one of list) { if (typeof one !== 'object' || one === null) continue; const file = one as Record; const ref = file['ref']; if (typeof ref !== 'string' || ref === '') continue; const size = file['size']; out.push({ index: out.length, name: typeof file['name'] === 'string' ? file['name'] : '未命名', contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream', size: typeof size === 'number' && Number.isFinite(size) ? size : 0, ref, }); } } return out; } /** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */ export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null { if (archivedNames.length > 0) return clip(archivedNames.join('、')); if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`); return null; } export type InstanceQuery = { readonly id?: string | undefined; readonly ownerId?: string | undefined; readonly state?: InstanceState | undefined; readonly startedFrom?: string | undefined; readonly startedTo?: string | undefined; }; function dayBound(day: string, end: boolean): number | undefined { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); if (m === null) return undefined; const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; // 服务进程的本地时区:单时区部署;「含两端整日」 return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime(); } /** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */ export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] { const needle = q.id?.trim().toLowerCase() ?? ''; const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false); const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true); return items.filter((it) => { if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false; if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false; if (q.state !== undefined && it.state !== q.state) return false; if (from !== undefined || to !== undefined) { const t = Date.parse(it.startedAt); if (!Number.isFinite(t)) return false; if (from !== undefined && t < from) return false; if (to !== undefined && t > to) return false; } return true; }); } export function statsOf(items: readonly InstanceSummary[]): InstanceStats { const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record; for (const it of items) byState[it.state] += 1; return { total: items.length, byState }; } export function pageOf(items: readonly T[], page: number, size: number): T[] { const start = (Math.max(1, page) - 1) * Math.max(1, size); return items.slice(start, start + Math.max(1, size)); }