feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -1,75 +1,77 @@
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'requests',
|
||||
'approval',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'requests',
|
||||
'approval',
|
||||
'archive',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
archive: ['finance', 'executive', 'auditor'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveLeaderExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
216
web/src/utils/archiveCenterListFilters.js
Normal file
216
web/src/utils/archiveCenterListFilters.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
isActionableRiskFlag,
|
||||
isRiskSummaryWithRisk,
|
||||
normalizeRiskFlagTone
|
||||
} from './riskFlags.js'
|
||||
|
||||
export const ARCHIVE_FILTER_ALL = 'all'
|
||||
|
||||
export function countClaimRisks(riskFlags, riskSummary) {
|
||||
let count = 0
|
||||
|
||||
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
count += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const points = Array.isArray(flag.points)
|
||||
? flag.points.map((point) => String(point || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (points.length) {
|
||||
count += points.length
|
||||
continue
|
||||
}
|
||||
|
||||
const message = String(
|
||||
flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title || ''
|
||||
).trim()
|
||||
|
||||
if (message) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!count && isRiskSummaryWithRisk(riskSummary)) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export function resolveArchiveRiskTone(riskFlags, riskSummary) {
|
||||
let tone = 'low'
|
||||
|
||||
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const flagTone = normalizeRiskFlagTone(flag)
|
||||
if (flagTone === 'high') {
|
||||
return 'high'
|
||||
}
|
||||
if (flagTone === 'medium') {
|
||||
tone = 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
if (tone === 'low' && isRiskSummaryWithRisk(riskSummary)) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
return tone
|
||||
}
|
||||
|
||||
export function formatArchiveRiskCountLabel(riskCount) {
|
||||
const count = Math.max(0, Number(riskCount) || 0)
|
||||
return `${count}条`
|
||||
}
|
||||
|
||||
export function extractArchiveMonth(...values) {
|
||||
for (const value of values) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsedDate = new Date(text)
|
||||
if (!Number.isNaN(parsedDate.getTime())) {
|
||||
const year = parsedDate.getFullYear()
|
||||
const month = String(parsedDate.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
const matched = text.match(/(\d{4})-(\d{2})/)
|
||||
if (matched) {
|
||||
return `${matched[1]}-${matched[2]}`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatArchiveMonthLabel(monthKey) {
|
||||
const normalized = String(monthKey || '').trim()
|
||||
const matched = normalized.match(/^(\d{4})-(\d{2})$/)
|
||||
if (!matched) {
|
||||
return normalized || '未知月份'
|
||||
}
|
||||
|
||||
return `${matched[1]}年${matched[2]}月`
|
||||
}
|
||||
|
||||
export function buildTypeFilterOptions(rows) {
|
||||
const typeMap = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
const value = String(row?.typeCode || 'other').trim() || 'other'
|
||||
if (!typeMap.has(value)) {
|
||||
typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部类型' },
|
||||
...Array.from(typeMap.entries())
|
||||
.sort((left, right) => left[1].localeCompare(right[1], 'zh-CN'))
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDepartmentFilterOptions(rows) {
|
||||
const departments = new Set()
|
||||
|
||||
for (const row of rows) {
|
||||
const department = String(row?.department || row?.dept || '').trim()
|
||||
if (department) {
|
||||
departments.add(department)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部部门' },
|
||||
...Array.from(departments)
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map((value) => ({ value, label: value }))
|
||||
]
|
||||
}
|
||||
|
||||
export function buildArchiveMonthFilterOptions(rows) {
|
||||
const months = new Set()
|
||||
|
||||
for (const row of rows) {
|
||||
const month = String(row?.archiveMonth || '').trim()
|
||||
if (month) {
|
||||
months.add(month)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部月份' },
|
||||
...Array.from(months)
|
||||
.sort((left, right) => right.localeCompare(left))
|
||||
.map((value) => ({ value, label: formatArchiveMonthLabel(value) }))
|
||||
]
|
||||
}
|
||||
|
||||
export function applyArchiveListFilters(rows, filters) {
|
||||
let filteredRows = Array.isArray(rows) ? [...rows] : []
|
||||
|
||||
if (filters.tab && filters.tab !== '全部归档') {
|
||||
filteredRows = filteredRows.filter((row) => row.archiveTab === filters.tab)
|
||||
}
|
||||
|
||||
if (filters.risk === 'has') {
|
||||
filteredRows = filteredRows.filter((row) => row.hasRisk)
|
||||
} else if (filters.risk === 'none') {
|
||||
filteredRows = filteredRows.filter((row) => !row.hasRisk)
|
||||
} else if (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL) {
|
||||
filteredRows = filteredRows.filter((row) => row.hasRisk && row.riskTone === filters.risk)
|
||||
}
|
||||
|
||||
if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) {
|
||||
filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type)
|
||||
}
|
||||
|
||||
if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) {
|
||||
filteredRows = filteredRows.filter((row) => String(row.department || '').trim() === filters.department)
|
||||
}
|
||||
|
||||
if (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL) {
|
||||
filteredRows = filteredRows.filter((row) => String(row.archiveMonth || '').trim() === filters.archiveMonth)
|
||||
}
|
||||
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
if (keyword) {
|
||||
filteredRows = filteredRows.filter((row) => (
|
||||
String(row.id || '').toLowerCase().includes(keyword)
|
||||
|| String(row.applicant || '').toLowerCase().includes(keyword)
|
||||
|| String(row.department || '').toLowerCase().includes(keyword)
|
||||
|| String(row.type || '').toLowerCase().includes(keyword)
|
||||
|| String(row.amount || '').toLowerCase().includes(keyword)
|
||||
|| String(row.risk || '').toLowerCase().includes(keyword)
|
||||
|| String(row.riskCount ?? '').includes(keyword)
|
||||
|| String(row.archiveMonthLabel || '').toLowerCase().includes(keyword)
|
||||
))
|
||||
}
|
||||
|
||||
return filteredRows
|
||||
}
|
||||
|
||||
export function hasActiveArchiveListFilters(filters) {
|
||||
return Boolean(
|
||||
(filters.tab && filters.tab !== '全部归档')
|
||||
|| (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL)
|
||||
|| (filters.type && filters.type !== ARCHIVE_FILTER_ALL)
|
||||
|| (filters.department && filters.department !== ARCHIVE_FILTER_ALL)
|
||||
|| (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL)
|
||||
|| String(filters.keyword || '').trim()
|
||||
)
|
||||
}
|
||||
@@ -22,12 +22,12 @@ function getStorage() {
|
||||
return window.localStorage
|
||||
}
|
||||
|
||||
function emitSnapshotChange(sessionType) {
|
||||
function emitSnapshotChange(sessionType, detail = {}) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
|
||||
detail: { sessionType: normalizeSessionType(sessionType) }
|
||||
detail: { sessionType: normalizeSessionType(sessionType), ...detail }
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -82,18 +82,39 @@ export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', s
|
||||
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedSessionType = normalizeSessionType(sessionType)
|
||||
try {
|
||||
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
|
||||
emitSnapshotChange(normalizedSessionType)
|
||||
emitSnapshotChange(normalizedSessionType, { action: 'clear' })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear assistant session snapshot:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
|
||||
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
|
||||
}
|
||||
|
||||
function resolveSnapshotDraftClaimId(snapshot) {
|
||||
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : {}
|
||||
return String(state.draftClaimId || state.draft_claim_id || '').trim()
|
||||
}
|
||||
|
||||
export function clearAssistantSessionSnapshotForDraftClaim(userId, claimId, sessionType = 'expense') {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const snapshot = readAssistantSessionSnapshot(userId, sessionType)
|
||||
if (resolveSnapshotDraftClaimId(snapshot) !== normalizedClaimId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return clearAssistantSessionSnapshot(userId, sessionType)
|
||||
}
|
||||
|
||||
114
web/src/utils/detailAlerts.js
Normal file
114
web/src/utils/detailAlerts.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItem(item) {
|
||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
|
||||
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
function hasPositiveAmount(value) {
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) && amount > 0
|
||||
}
|
||||
|
||||
function getExpenseItems(request) {
|
||||
return Array.isArray(request?.expenseItems) ? request.expenseItems : []
|
||||
}
|
||||
|
||||
export function hasMissingAttachment(request) {
|
||||
const expenseItems = getExpenseItems(request)
|
||||
|
||||
if (expenseItems.length) {
|
||||
return expenseItems.some((item) => {
|
||||
if (isSystemGeneratedExpenseItem(item)) {
|
||||
return false
|
||||
}
|
||||
return !String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const attachmentSummary = String(request?.attachmentSummary || '').trim()
|
||||
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
|
||||
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
|
||||
}
|
||||
|
||||
export function hasPendingInfo(request) {
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expenseItems = getExpenseItems(request).filter((item) => !isSystemGeneratedExpenseItem(item))
|
||||
const hasItemValue = (resolver) => expenseItems.some((item) => !isPlaceholderValue(resolver(item)))
|
||||
const hasItemAmount = expenseItems.some((item) => hasPositiveAmount(item?.itemAmount || item?.item_amount))
|
||||
const requestType = normalizeExpenseType(request.typeCode || request.expense_type)
|
||||
const locationRequired = LOCATION_REQUIRED_EXPENSE_TYPES.has(requestType)
|
||||
|
||||
if (!hasPositiveAmount(request.amountValue) && !hasItemAmount) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isPlaceholderValue(request.typeLabel) && !hasItemValue((item) => item?.itemType || item?.item_type)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isPlaceholderValue(request.reason) && !hasItemValue((item) => item?.itemReason || item?.item_reason || item?.desc)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isPlaceholderValue(request.occurredDisplay) && !hasItemValue((item) => item?.itemDate || item?.item_date || item?.time)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
locationRequired
|
||||
&& isPlaceholderValue(request.location)
|
||||
&& isPlaceholderValue(request.city)
|
||||
&& !hasItemValue((item) => item?.itemLocation || item?.item_location)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveDetailAlertTone(request) {
|
||||
if (request?.approvalKey === 'completed') return 'success'
|
||||
if (request?.approvalKey === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
export function buildDetailAlerts(request) {
|
||||
if (!request) {
|
||||
return []
|
||||
}
|
||||
|
||||
const alerts = []
|
||||
const nodeLabel = String(request.node || request.approval || '').trim()
|
||||
|
||||
if (nodeLabel) {
|
||||
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
|
||||
}
|
||||
|
||||
if (hasMissingAttachment(request)) {
|
||||
alerts.push({ label: '缺少票据', tone: 'warning' })
|
||||
}
|
||||
|
||||
if (hasPendingInfo(request)) {
|
||||
alerts.push({ label: '待补信息', tone: 'warning' })
|
||||
}
|
||||
|
||||
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
|
||||
}
|
||||
14
web/src/utils/expenseClaimArchive.js
Normal file
14
web/src/utils/expenseClaimArchive.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export function isArchivedExpenseClaim(claim) {
|
||||
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
|
||||
const status = String(claim?.status || '').trim().toLowerCase()
|
||||
|
||||
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!['approved', 'completed', 'paid'].includes(status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !stage || stage === '归档入账' || stage === 'completed'
|
||||
}
|
||||
@@ -8,6 +8,76 @@ const markdown = new MarkdownIt({
|
||||
|
||||
const defaultTableOpen = markdown.renderer.rules.table_open
|
||||
const defaultTableClose = markdown.renderer.rules.table_close
|
||||
const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
|
||||
const defaultLinkOpen = markdown.renderer.rules.link_open
|
||||
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
|
||||
|
||||
const ACTION_LINK_CLASS_BY_HREF = {
|
||||
'#confirm-attachment-association': 'markdown-action-link-confirm'
|
||||
}
|
||||
|
||||
function resolveActionLinkClass(href) {
|
||||
const normalizedHref = String(href || '').trim()
|
||||
return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || ''
|
||||
}
|
||||
|
||||
function inlineTokenHasActionLink(token) {
|
||||
const children = Array.isArray(token?.children) ? token.children : []
|
||||
return children.some((child) => (
|
||||
child?.type === 'link_open' && resolveActionLinkClass(child.attrGet?.('href'))
|
||||
))
|
||||
}
|
||||
|
||||
function resolveInlineTokenPlainText(token) {
|
||||
const children = Array.isArray(token?.children) ? token.children : []
|
||||
const childText = children
|
||||
.filter((child) => ['text', 'code_inline'].includes(String(child?.type || '')))
|
||||
.map((child) => String(child?.content || ''))
|
||||
.join('')
|
||||
.trim()
|
||||
return childText || String(token?.content || '').replace(/[*_`]+/g, '').trim()
|
||||
}
|
||||
|
||||
function blockquoteHasAttachmentHeading(tokens, idx) {
|
||||
for (let i = idx + 1; i < tokens.length; i += 1) {
|
||||
const token = tokens[i]
|
||||
if (token?.type === 'blockquote_close') {
|
||||
return false
|
||||
}
|
||||
if (token?.type === 'inline') {
|
||||
return /^附件\s*\d+\s*[::]/.test(resolveInlineTokenPlainText(token))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
markdown.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => {
|
||||
if (inlineTokenHasActionLink(tokens[idx + 1])) {
|
||||
tokens[idx].attrJoin('class', 'markdown-action-paragraph')
|
||||
}
|
||||
return defaultParagraphOpen
|
||||
? defaultParagraphOpen(tokens, idx, options, env, self)
|
||||
: self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const actionClass = resolveActionLinkClass(tokens[idx].attrGet('href'))
|
||||
if (actionClass) {
|
||||
tokens[idx].attrJoin('class', `markdown-action-link ${actionClass}`)
|
||||
}
|
||||
return defaultLinkOpen
|
||||
? defaultLinkOpen(tokens, idx, options, env, self)
|
||||
: self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
|
||||
if (blockquoteHasAttachmentHeading(tokens, idx)) {
|
||||
tokens[idx].attrJoin('class', 'markdown-attachment-card')
|
||||
}
|
||||
return defaultBlockquoteOpen
|
||||
? defaultBlockquoteOpen(tokens, idx, options, env, self)
|
||||
: self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => (
|
||||
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`
|
||||
|
||||
107
web/src/utils/riskFlags.js
Normal file
107
web/src/utils/riskFlags.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const NO_RISK_SUMMARY_VALUES = new Set(['无', '暂无异常', '无异常', '暂无风险'])
|
||||
const NON_RISK_SOURCES = new Set([
|
||||
'manual_approval',
|
||||
'finance_approval',
|
||||
'approval',
|
||||
'approval_log',
|
||||
'expense_claim_approval',
|
||||
'expense_claim_finance_approval'
|
||||
])
|
||||
const NON_RISK_EVENTS = new Set([
|
||||
'expense_claim_approval',
|
||||
'expense_claim_finance_approval'
|
||||
])
|
||||
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
|
||||
const RISK_SOURCES = new Set([
|
||||
'attachment_analysis',
|
||||
'submission_review',
|
||||
'manual_return',
|
||||
'platform_risk',
|
||||
'policy_review',
|
||||
'scene_policy_review'
|
||||
])
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeKey(value) {
|
||||
return normalizeText(value).toLowerCase()
|
||||
}
|
||||
|
||||
function isApprovalOnlyText(value) {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
/^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text)
|
||||
|| /已审批通过/.test(text)
|
||||
|| /已完成财务审核/.test(text)
|
||||
|| /进入归档入账/.test(text)
|
||||
|| /流转至/.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeRiskFlagTone(flag) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return normalizeText(flag) ? 'medium' : 'none'
|
||||
}
|
||||
|
||||
const tone = normalizeKey(flag.severity || flag.tone || flag.level || flag.riskTone || flag.risk_tone)
|
||||
if (['high', 'medium', 'low'].includes(tone)) {
|
||||
return tone
|
||||
}
|
||||
if (NON_RISK_TONES.has(tone)) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const source = normalizeKey(flag.source)
|
||||
if (source === 'manual_return') {
|
||||
return 'medium'
|
||||
}
|
||||
if (RISK_SOURCES.has(source)) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
const riskText = normalizeText(flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title)
|
||||
if (riskText && !isApprovalOnlyText(riskText)) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
return 'none'
|
||||
}
|
||||
|
||||
export function isActionableRiskFlag(flag) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const text = normalizeText(flag)
|
||||
return Boolean(text && !isApprovalOnlyText(text))
|
||||
}
|
||||
|
||||
const source = normalizeKey(flag.source)
|
||||
const eventType = normalizeKey(flag.event_type || flag.eventType)
|
||||
if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tone = normalizeRiskFlagTone(flag)
|
||||
if (tone === 'high' || tone === 'medium' || tone === 'low') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function filterActionableRiskFlags(riskFlags) {
|
||||
return (Array.isArray(riskFlags) ? riskFlags : []).filter((flag) => isActionableRiskFlag(flag))
|
||||
}
|
||||
|
||||
export function isRiskSummaryWithRisk(riskSummary) {
|
||||
const summary = normalizeText(riskSummary)
|
||||
if (!summary || NO_RISK_SUMMARY_VALUES.has(summary) || isApprovalOnlyText(summary)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user