/** * 右侧信息栏的共享类型与纯函数(2026-11-06) * * ## 为什么抽出来 * * 右栏原本整个写死在日常对话页里。群聊页要做成一致的观感就必然需要同一套: * 四个标签、拖宽、收起、本地记忆。复制一份会产生两份会各自漂移的实现, * 所以抽成共享层,日常页与群聊页都从这里取。 * * 本文件**只有纯函数与类型**,不依赖 Angular,便于直接单测。 */ /** 右栏标签(顺序即显示顺序) */ export type RailTab = 'tasks' | 'files' | 'results' | 'status'; /** 标签定义(模板遍历用,避免在模板里重复写四份) */ export interface RailTabDef { key: RailTab; label: string; icon: string; hint: string; } /** * 四个标签的默认定义。 * * 群聊页把 `hint` 换成多成员语义(如「群成员的任务」), * 标签名与图标保持不变 —— 用户在两处看到的按钮是同一组。 */ export const RAIL_TABS: RailTabDef[] = [ { key: 'tasks', label: '任务', icon: 'fa-list-check', hint: '名下的任务' }, { key: 'files', label: '文件', icon: 'fa-paperclip', hint: '本次对话产出的文件' }, { key: 'results', label: '成果', icon: 'fa-box', hint: '平台上报的交付成果' }, { key: 'status', label: '状态', icon: 'fa-gauge-high', hint: '运行指标与最近动态' }, ]; const RAIL_TAB_KEY = 'fmode_rail_tab'; /** * 读上次选中的标签。 * * 默认「文件」—— 用户最关心这次对话产出了什么。 */ export function readRailTabPref(): RailTab { try { const v = localStorage.getItem(RAIL_TAB_KEY); if (v === 'tasks' || v === 'files' || v === 'results' || v === 'status') return v; } catch { /* 隐私模式 */ } return 'files'; } /** 记住用户的选择(跨刷新保持) */ export function saveRailTabPref(tab: RailTab): void { try { localStorage.setItem(RAIL_TAB_KEY, tab); } catch { /* 隐私模式:只保留内存态 */ } } /** 右栏宽度的本地存储键(日常页与群聊页共用同一偏好) */ export const RAIL_W_KEY = 'fmode_rail_w'; export const RAIL_W_MIN = 280; export const RAIL_W_MAX = 560; export const RAIL_W_DEFAULT = 320; /** 预览形态下的宽度上限:看文档/表格时 560 太窄 */ export const RAIL_W_PREVIEW_MAX = 720; /** * 右栏宽度夹取 —— **纯函数**,便于单测。 * * 下限 280:再窄右栏里「文件名 + 大小」会折行成两三行; * 上限 560:再宽就把对话区挤到不可读(主区最小可读宽约 480)。 */ export function clampRailWidth(w: number): number { if (!Number.isFinite(w)) return RAIL_W_DEFAULT; return Math.min(RAIL_W_MAX, Math.max(RAIL_W_MIN, Math.round(w))); } /** 预览形态下的宽度夹取(上限放宽到 720) */ export function clampPreviewRailWidth(w: number): number { return Math.min( RAIL_W_PREVIEW_MAX, Math.max(RAIL_W_MIN, Math.round(Number.isFinite(w) ? w : RAIL_W_DEFAULT)), ); } /** 按 mimeType 选文件图标(原生附件没有 fileType 字段) */ export function fileIconByMime(mime: string): string { if (mime.startsWith('image/')) return 'fa-file-image'; if (mime.includes('html')) return 'fa-file-code'; if (mime.includes('pdf')) return 'fa-file-pdf'; if (mime.includes('word') || mime.includes('officedocument.word')) return 'fa-file-word'; if (mime.includes('sheet') || mime.includes('excel')) return 'fa-file-excel'; if (mime.includes('zip')) return 'fa-file-zipper'; return 'fa-file-lines'; } /** 按成果文件后缀选图标 */ export function fileIconByType(type?: string): string { const t = (type || '').toLowerCase(); if (t.includes('pdf')) return 'fa-file-pdf'; if (t.includes('doc')) return 'fa-file-word'; if (t.includes('xls') || t.includes('sheet') || t.includes('csv')) return 'fa-file-excel'; if (t.includes('ppt')) return 'fa-file-powerpoint'; if (t.includes('zip') || t.includes('rar')) return 'fa-file-zipper'; if (t.includes('png') || t.includes('jpg') || t.includes('jpeg') || t.includes('image')) return 'fa-file-image'; if (t.includes('md') || t.includes('txt')) return 'fa-file-lines'; return 'fa-file'; } /** 字节数 → 人类可读 */ export function fmtSize(bytes: number): string { if (!bytes) return ''; /* * 负数 = 「大小未知」(2026-11-09)。云盘链接不带大小, * 显示「-1 B」会让用户以为文件坏了,这里当作「不显示」。 */ if (bytes < 0) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; }