import dayjs from 'dayjs' import { request } from '@/core/request' import type { ApiResponse } from '@/types/api' export interface Article { id: string title: string content: string author: string createdAt: string updatedAt: string } export interface ArticlePayload { title: string content: string } type RawRecord = Record const LIST_URL = '/uaa/announcement/get/restricted' const DETAIL_URL = '/uaa/announcement/get/id' const ADD_URL = '/uaa/announcement/add' const UPDATE_URL = '/uaa/announcement/update' const ALLOWED_TAGS = new Set([ 'A', 'B', 'BLOCKQUOTE', 'BR', 'DIV', 'EM', 'H2', 'H3', 'I', 'IMG', 'LI', 'OL', 'P', 'PRE', 'S', 'SPAN', 'STRONG', 'U', 'UL', 'CODE', ]) const ALLOWED_IMAGE_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', ]) const BASE64_IMAGE_SIGNATURES = [ { mime: 'image/png', pattern: /^iVBORw0KGgo/ }, { mime: 'image/jpeg', pattern: /^\/9j\// }, { mime: 'image/gif', pattern: /^R0lGOD/ }, { mime: 'image/webp', pattern: /^UklGR/ }, { mime: 'image/bmp', pattern: /^Qk/ }, ] export async function queryArticles() { const response = await request({ url: LIST_URL, method: 'GET', }) const data = unwrapResponse(response) return extractRecords(data).map((record) => mapAnnouncementRecord(record)) } export async function getArticleDetail(annouId: string) { const response = await request({ url: DETAIL_URL, method: 'GET', params: { annouId }, }) const data = unwrapResponse(response) const record = extractDetailRecord(data) return mapAnnouncementRecord(record, annouId) } export async function createArticle(payload: ArticlePayload) { const content = sanitizeArticleHtml(payload.content) const response = await request({ url: ADD_URL, method: 'POST', data: buildAnnouncementPayload({ ...payload, content }), }) const data = unwrapResponse(response) const record = extractDetailRecord(data) return mapAnnouncementRecord(record, readString(record, ['annouId', 'id'])) } export async function updateArticle(annouId: string, payload: ArticlePayload) { const content = sanitizeArticleHtml(payload.content) const response = await request({ url: UPDATE_URL, method: 'POST', data: buildAnnouncementPayload({ ...payload, content }, annouId), }) const data = unwrapResponse(response) const record = extractDetailRecord(data) return mapAnnouncementRecord(record, annouId) } export function formatArticleDate(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) { return '' } return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date) } export function extractTextFromHtml(html: string) { if (!html.trim()) { return '' } const doc = new DOMParser().parseFromString(html, 'text/html') return doc.body.textContent?.replace(/\s+/g, ' ').trim() || '' } export function sanitizeArticleHtml(html: string) { if (!html.trim()) { return '' } const doc = new DOMParser().parseFromString(`
${html}
`, 'text/html') const root = doc.body.firstElementChild if (!root) { return '' } sanitizeChildren(root, doc) return root.innerHTML.trim() } function buildAnnouncementPayload(payload: ArticlePayload, annouId = '') { return { annouTitle: payload.title.trim(), annouConte: payload.content, annouId, annouZrFteLag: '0', annouSts: '0', department: '100011', effDt: dayjs().format('YYYY-MM-DD HH:mm:ss'), expireDt: dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss'), orgList: [], orgNo: '25001', roleList: [], topFlag: '0', userList: [], } } function unwrapResponse(response: ApiResponse | unknown) { if (!isRecord(response)) { return response } if ('code' in response && 'data' in response) { return response.data } return response } function extractRecords(data: unknown): RawRecord[] { const candidates = [ data, getValue(data, ['list']), getValue(data, ['records']), getValue(data, ['rows']), getValue(data, ['resultList']), getValue(data, ['content']), getValue(data, ['body', 'list']), getValue(data, ['body', 'records']), getValue(data, ['body', 'rows']), getValue(data, ['body', 'resultList']), ] for (const candidate of candidates) { if (Array.isArray(candidate)) { return candidate.filter(isRecord) } } return [] } function extractDetailRecord(data: unknown): RawRecord { const records = extractRecords(data) if (records.length > 0) { return records[0]! } const candidates = [ data, getValue(data, ['announcement']), getValue(data, ['announcementInfo']), getValue(data, ['article']), getValue(data, ['detail']), getValue(data, ['info']), getValue(data, ['record']), getValue(data, ['body']), getValue(data, ['body', 'announcement']), getValue(data, ['body', 'detail']), getValue(data, ['body', 'info']), ] return candidates.find(isRecord) || {} } function mapAnnouncementRecord(record: RawRecord, fallbackId = ''): Article { const now = new Date().toISOString() const id = readString(record, ['annouId', 'announcementId', 'articleId', 'id', 'uuid']) || fallbackId const title = readString(record, [ 'annouTitle', 'announTitle', 'announcementTitle', 'title', 'name', 'subject', ]) const content = readString(record, [ 'annouConte', 'announContent', 'announcementContent', 'articleContent', 'contentHtml', 'htmlContent', 'content', ]) const createdAt = readString(record, ['createTime', 'createdAt', 'publishTime', 'pubTime', 'gmtCreate']) || now const updatedAt = readString(record, ['updateTime', 'updatedAt', 'modifyTime', 'gmtModified']) || createdAt return { id, title: title || '未命名公告', content: sanitizeArticleHtml(content), author: readString(record, ['author', 'publisherName', 'pubUserName', 'creatorName', 'createBy']) || '管理员', createdAt: normalizeDate(createdAt), updatedAt: normalizeDate(updatedAt), } } function readString(record: RawRecord, keys: string[]) { for (const key of keys) { const value = record[key] if (typeof value === 'string' && value.trim()) { return value.trim() } if (typeof value === 'number') { return String(value) } } return '' } function getValue(value: unknown, path: string[]) { return path.reduce((current, key) => { if (!isRecord(current)) { return undefined } return current[key] }, value) } function normalizeDate(value: string) { if (/^\d{4}-\d{2}-\d{2}/.test(value)) { return value } if (/^\d{14}$/.test(value)) { return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}:${value.slice(12, 14)}` } if (/^\d{8}$/.test(value)) { return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T00:00:00` } return value } function isRecord(value: unknown): value is RawRecord { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } function sanitizeChildren(parent: ParentNode, doc: Document) { Array.from(parent.childNodes).forEach((child) => { if (child.nodeType === Node.COMMENT_NODE) { child.remove() return } if (child.nodeType !== Node.ELEMENT_NODE) { return } const element = child as HTMLElement if (!ALLOWED_TAGS.has(element.tagName)) { const fragment = doc.createDocumentFragment() while (element.firstChild) { fragment.appendChild(element.firstChild) } element.replaceWith(fragment) sanitizeChildren(parent, doc) return } sanitizeAttributes(element) sanitizeChildren(element, doc) }) } function sanitizeAttributes(element: HTMLElement) { Array.from(element.attributes).forEach((attribute) => { const name = attribute.name.toLowerCase() if (element.tagName === 'IMG') { sanitizeImageAttribute(element, attribute) return } if (element.tagName === 'A' && name === 'href') { if (!isSafeHref(attribute.value)) { element.removeAttribute(attribute.name) return } element.setAttribute('target', '_blank') element.setAttribute('rel', 'noopener noreferrer') return } if (element.tagName === 'A' && (name === 'target' || name === 'rel')) { return } if (element.tagName === 'CODE' && name === 'class') { const languageClass = normalizeCodeLanguageClass(attribute.value) if (languageClass) { element.setAttribute('class', languageClass) return } } if (element.tagName === 'CODE' && name === 'data-language') { const language = normalizeCodeLanguage(attribute.value) if (language) { element.setAttribute('data-language', language) return } } element.removeAttribute(attribute.name) }) if (element.tagName === 'IMG') { finalizeImageElement(element) } } function sanitizeImageAttribute(element: HTMLElement, attribute: Attr) { const name = attribute.name.toLowerCase() if (name === 'src') { const src = normalizeImageSource(attribute.value) if (src) { element.setAttribute('src', src) return } } if (name === 'alt' || name === 'title') { element.setAttribute(name, attribute.value.trim().slice(0, 120)) return } if (name === 'width' || name === 'height') { const dimension = normalizeImageDimension(attribute.value) if (dimension) { element.setAttribute(name, dimension) return } } element.removeAttribute(attribute.name) } function finalizeImageElement(element: HTMLElement) { if (!element.getAttribute('src')) { element.remove() return } if (!element.hasAttribute('alt')) { element.setAttribute('alt', '') } element.setAttribute('loading', 'lazy') element.setAttribute('decoding', 'async') } function normalizeImageSource(value: string) { const source = value.trim() if (!source) { return '' } const dataImageSource = normalizeDataImageSource(source) if (dataImageSource) { return dataImageSource } const bareBase64Source = normalizeBareBase64ImageSource(source) if (bareBase64Source) { return bareBase64Source } try { const url = new URL(source, window.location.origin) return ['http:', 'https:'].includes(url.protocol) ? source : '' } catch { return '' } } function normalizeDataImageSource(value: string) { const match = value.match(/^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i) if (!match) { return '' } const mime = normalizeImageMimeType(match[1] || '') const base64 = normalizeBase64Payload(match[2] || '') if (!mime || !isBase64Payload(base64)) { return '' } return `data:${mime};base64,${base64}` } function normalizeBareBase64ImageSource(value: string) { const base64 = normalizeBase64Payload(value) const mime = getBase64ImageMimeType(base64) if (!mime || !isBase64Payload(base64)) { return '' } return `data:${mime};base64,${base64}` } function normalizeImageMimeType(value: string) { const mime = value.trim().toLowerCase().replace(/^image\/jpg$/, 'image/jpeg') return ALLOWED_IMAGE_MIME_TYPES.has(mime) ? mime : '' } function normalizeBase64Payload(value: string) { return value.replace(/\s+/g, '') } function isBase64Payload(value: string) { return Boolean(value) && value.length % 4 !== 1 && /^[A-Za-z0-9+/]+={0,2}$/.test(value) } function getBase64ImageMimeType(value: string) { return BASE64_IMAGE_SIGNATURES.find(({ pattern }) => pattern.test(value))?.mime || '' } function normalizeImageDimension(value: string) { const dimension = Number.parseInt(value, 10) if (!Number.isFinite(dimension) || dimension <= 0 || dimension > 4096) { return '' } return String(dimension) } function normalizeCodeLanguageClass(value: string) { const language = value .split(/\s+/) .find((className) => className.startsWith('language-')) ?.replace(/^language-/, '') if (!language) { return '' } return `language-${normalizeCodeLanguage(language)}` } function normalizeCodeLanguage(value: string) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 24) } function isSafeHref(value: string) { const href = value.trim() if (href.startsWith('#')) { return true } try { const url = new URL(href, window.location.origin) return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol) } catch { return false } }