Archived
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params / summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts` (与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema); 迁移 0035_activity_ops;《API文档-活动模块》按契约回写。 设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*` 整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。 2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
115 lines
5.3 KiB
TypeScript
115 lines
5.3 KiB
TypeScript
/**
|
|
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
|
|
*/
|
|
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<Record<string, unknown>>, 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<Record<string, unknown>>, 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<string, unknown>;
|
|
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<InstanceState, number>;
|
|
for (const it of items) byState[it.state] += 1;
|
|
return { total: items.length, byState };
|
|
}
|
|
|
|
export function pageOf<T>(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));
|
|
}
|