feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -12,7 +12,18 @@
|
||||
:loading-message="`正在加载${activeTabLabel}资产`"
|
||||
loading-icon="mdi mdi-view-list-outline"
|
||||
:hint="hintText"
|
||||
:show-pagination="!loading && !errorMessage && visibleSkills.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleSkills.length"
|
||||
:total-pages="totalPages"
|
||||
@update:active-tab="emit('update:activeType', $event)"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="changePageSize"
|
||||
@empty-action="emit('empty-action')"
|
||||
>
|
||||
<template #filters>
|
||||
@@ -178,7 +189,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in visibleSkills"
|
||||
v-for="skill in pagedSkills"
|
||||
:key="skill.id"
|
||||
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
|
||||
@click="emit('open-asset-detail', skill)"
|
||||
@@ -221,17 +232,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
|
||||
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
|
||||
</footer>
|
||||
</template>
|
||||
</EnterpriseListPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AuditPickerFilter from './AuditPickerFilter.vue'
|
||||
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
|
||||
@@ -310,9 +315,66 @@ const shellTabs = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({
|
||||
label: `${size} 条/页`,
|
||||
value: size
|
||||
}))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleSkills.length / pageSize.value)))
|
||||
const pagedSkills = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleSkills.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.activeType,
|
||||
props.keyword,
|
||||
props.selectedDomain,
|
||||
props.selectedOwner,
|
||||
props.selectedRiskLevel,
|
||||
props.selectedStatus,
|
||||
props.selectedRiskScenario,
|
||||
props.selectedOnlineState,
|
||||
props.selectedEnabledState
|
||||
],
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visibleSkills.length,
|
||||
() => {
|
||||
currentPage.value = Math.min(currentPage.value, totalPages.value)
|
||||
if (currentPage.value < 1) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function selectFilter(type, value) {
|
||||
emit('select-filter', type, value)
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
const nextSize = Number(size) || 10
|
||||
pageSize.value = nextSize
|
||||
currentPage.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
|
||||
@@ -16,6 +16,30 @@
|
||||
@confirm="emit('submit-risk-rule-create')"
|
||||
>
|
||||
<div class="risk-rule-create-form">
|
||||
<label class="span-2">
|
||||
<span>常见规则模板</span>
|
||||
<EnterpriseSelect
|
||||
v-model="selectedRiskRuleTemplateId"
|
||||
:options="riskRuleTemplateOptions"
|
||||
:disabled="riskRuleCreateBusy || riskRuleTemplatesLoading"
|
||||
:placeholder="riskRuleTemplatesLoading ? '模板加载中...' : '可选:从常见规则预填'"
|
||||
clearable
|
||||
filterable
|
||||
@change="applyRiskRuleTemplate"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="selectedRiskRuleTemplate" class="risk-rule-template-preview span-2">
|
||||
<div class="risk-rule-template-preview__head">
|
||||
<strong>{{ selectedRiskRuleTemplate.title }}</strong>
|
||||
<span>{{ selectedRiskRuleTemplate.group_label }}</span>
|
||||
</div>
|
||||
<p>{{ selectedRiskRuleTemplate.description }}</p>
|
||||
<small>字段:{{ formatTemplateFields(selectedRiskRuleTemplate.fields) }}</small>
|
||||
<small>模板只预填规则文本,提交后仍走通用自然语言生成链路。</small>
|
||||
</div>
|
||||
<p v-else-if="riskRuleTemplateLoadFailed" class="risk-rule-template-note span-2">
|
||||
常见模板暂时加载失败,可以继续手动编写规则。
|
||||
</p>
|
||||
<label>
|
||||
<span>业务环节</span>
|
||||
<EnterpriseSelect
|
||||
@@ -72,9 +96,9 @@
|
||||
badge="规则维护"
|
||||
badge-tone="info"
|
||||
:title="riskRuleEditMode === 'revision' ? '创建修订版本' : '编辑风险规则'"
|
||||
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述。'"
|
||||
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿并重新生成执行模板。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述,并同步重新生成执行模板。'"
|
||||
cancel-text="取消"
|
||||
:confirm-text="riskRuleEditMode === 'revision' ? '创建修订' : '保存草稿'"
|
||||
:confirm-text="riskRuleEditMode === 'revision' ? '创建并生成' : '保存并生成'"
|
||||
busy-text="保存中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-content-save-outline"
|
||||
@@ -280,10 +304,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog.vue'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import RiskRuleTestDialog from '../shared/RiskRuleTestDialog.vue'
|
||||
import { fetchRiskRuleTemplates } from '../../services/agentAssets.js'
|
||||
|
||||
defineOptions({
|
||||
name: 'AuditRuleDialogs'
|
||||
@@ -354,7 +379,123 @@ const reviewSubmitReviewerModel = computed({
|
||||
get: () => props.reviewSubmitReviewer,
|
||||
set: (value) => emit('update:reviewSubmitReviewer', value)
|
||||
})
|
||||
|
||||
const riskRuleTemplateGroups = ref([])
|
||||
const riskRuleTemplatesLoading = ref(false)
|
||||
const riskRuleTemplateLoadFailed = ref(false)
|
||||
const selectedRiskRuleTemplateId = ref('')
|
||||
|
||||
const flatRiskRuleTemplates = computed(() =>
|
||||
riskRuleTemplateGroups.value.flatMap((group) => group.templates || [])
|
||||
)
|
||||
const riskRuleTemplateOptions = computed(() =>
|
||||
flatRiskRuleTemplates.value.map((template) => ({
|
||||
value: template.template_id,
|
||||
label: `${template.group_label} / ${template.title}`
|
||||
}))
|
||||
)
|
||||
const selectedRiskRuleTemplate = computed(() =>
|
||||
flatRiskRuleTemplates.value.find((template) => template.template_id === selectedRiskRuleTemplateId.value) || null
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.riskRuleCreateOpen,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
selectedRiskRuleTemplateId.value = ''
|
||||
return
|
||||
}
|
||||
loadRiskRuleTemplates()
|
||||
}
|
||||
)
|
||||
|
||||
async function loadRiskRuleTemplates() {
|
||||
if (riskRuleTemplateGroups.value.length || riskRuleTemplatesLoading.value) {
|
||||
return
|
||||
}
|
||||
riskRuleTemplatesLoading.value = true
|
||||
riskRuleTemplateLoadFailed.value = false
|
||||
try {
|
||||
riskRuleTemplateGroups.value = await fetchRiskRuleTemplates()
|
||||
} catch {
|
||||
riskRuleTemplateLoadFailed.value = true
|
||||
} finally {
|
||||
riskRuleTemplatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyRiskRuleTemplate(templateId) {
|
||||
const template = flatRiskRuleTemplates.value.find((item) => item.template_id === templateId)
|
||||
if (!template || !props.riskRuleCreateForm) {
|
||||
return
|
||||
}
|
||||
|
||||
props.riskRuleCreateForm.business_domain = template.business_domain || 'expense'
|
||||
props.riskRuleCreateForm.business_stage = template.business_stage || 'reimbursement'
|
||||
props.riskRuleCreateForm.expense_category = template.expense_category || props.riskRuleCreateForm.expense_category
|
||||
props.riskRuleCreateForm.rule_title = template.title || ''
|
||||
props.riskRuleCreateForm.requires_attachment = Boolean(template.requires_attachment)
|
||||
props.riskRuleCreateForm.natural_language = template.natural_language || ''
|
||||
}
|
||||
|
||||
function formatTemplateFields(fields = []) {
|
||||
const labels = fields.map((field) => field.display || field.label || field.key).filter(Boolean)
|
||||
if (!labels.length) {
|
||||
return '未配置字段'
|
||||
}
|
||||
if (labels.length <= 4) {
|
||||
return labels.join('、')
|
||||
}
|
||||
return `${labels.slice(0, 4).join('、')} 等 ${labels.length} 项`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
|
||||
<style scoped>
|
||||
.risk-rule-create-form > .span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.risk-rule-template-preview {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-rule-template-preview__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-rule-template-preview__head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-rule-template-preview__head span,
|
||||
.risk-rule-template-preview small,
|
||||
.risk-rule-template-note {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-rule-template-preview p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-rule-template-note {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
:show-pagination="!loading && !errorMessage && visibleEmployees.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="false"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleEmployees.length"
|
||||
:total-pages="totalPages"
|
||||
@@ -18,6 +19,8 @@
|
||||
loading-message="正在加载数字员工资产"
|
||||
loading-icon="mdi mdi-view-list-outline"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@page-size-change="changePageSize"
|
||||
>
|
||||
<template #filters>
|
||||
<label class="list-search">
|
||||
@@ -43,6 +46,19 @@
|
||||
@select="selectFilter('status', $event)"
|
||||
/>
|
||||
|
||||
<AuditPickerFilter
|
||||
id="skillCategory"
|
||||
title="选择技能类型"
|
||||
close-label="关闭技能类型选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="selectedSkillCategoryLabel"
|
||||
:options="skillCategoryOptions"
|
||||
:selected-value="selectedSkillCategory"
|
||||
@toggle="emit('toggle-filter-popover', $event)"
|
||||
@close="emit('close-filter-popover')"
|
||||
@select="selectFilter('skillCategory', $event)"
|
||||
/>
|
||||
|
||||
<AuditPickerFilter
|
||||
id="enabled"
|
||||
title="选择启动状态"
|
||||
@@ -172,6 +188,9 @@ const props = defineProps({
|
||||
selectedStatus: { type: String, default: '' },
|
||||
selectedStatusLabel: { type: String, default: '' },
|
||||
statusOptions: { type: Array, default: () => [] },
|
||||
selectedSkillCategory: { type: String, default: '' },
|
||||
selectedSkillCategoryLabel: { type: String, default: '' },
|
||||
skillCategoryOptions: { type: Array, default: () => [] },
|
||||
selectedEnabledState: { type: String, default: '' },
|
||||
selectedEnabledLabel: { type: String, default: '' },
|
||||
enabledStateOptions: { type: Array, default: () => [] },
|
||||
@@ -195,11 +214,16 @@ const emit = defineEmits([
|
||||
])
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize)))
|
||||
const pageSize = ref(10)
|
||||
const pageSizeOptions = [
|
||||
{ label: '10 条/页', value: 10 },
|
||||
{ label: '20 条/页', value: 20 },
|
||||
{ label: '50 条/页', value: 50 }
|
||||
]
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize.value)))
|
||||
const pagedEmployees = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return props.visibleEmployees.slice(start, start + pageSize)
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleEmployees.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
@@ -210,7 +234,7 @@ const pageNumbers = computed(() => {
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
const emptyState = {
|
||||
eyebrow: '数字员工',
|
||||
@@ -223,7 +247,13 @@ const emptyState = {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
|
||||
() => [
|
||||
props.keyword,
|
||||
props.selectedStatus,
|
||||
props.selectedSkillCategory,
|
||||
props.selectedEnabledState,
|
||||
props.selectedExecutionMode
|
||||
],
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
@@ -243,6 +273,11 @@ watch(
|
||||
function selectFilter(type, value) {
|
||||
emit('select-filter', type, value)
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
pageSize.value = Number(size) || 10
|
||||
currentPage.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
|
||||
|
||||
@@ -110,6 +110,62 @@
|
||||
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察。</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>待复核线索</h4>
|
||||
<span>{{ riskClues.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="riskClues.length" class="run-product-observation-list">
|
||||
<article
|
||||
v-for="item in riskClues"
|
||||
:key="item.clue_id || item.clueId"
|
||||
class="run-product-observation"
|
||||
>
|
||||
<div class="run-product-observation-head">
|
||||
<span class="risk-level-pill" :class="item.risk_level || item.riskLevel">
|
||||
{{ formatRiskLevel(item.risk_level || item.riskLevel) }}
|
||||
</span>
|
||||
<strong>{{ item.title || formatSignal(item.risk_signal || item.riskSignal) }}</strong>
|
||||
<b>{{ formatConfidence(item.confidence_score ?? item.confidenceScore) }}</b>
|
||||
</div>
|
||||
<p>{{ item.summary || '该线索需要人工复核事实和证据。' }}</p>
|
||||
<div class="run-product-tags">
|
||||
<span>单据:{{ item.claim_no || item.claimNo || '-' }}</span>
|
||||
<span>状态:待人工复核</span>
|
||||
<span v-if="item.observation_key || item.observationKey">
|
||||
观察:{{ item.observation_key || item.observationKey }}
|
||||
</span>
|
||||
<span v-if="item.feedback_status || item.feedbackStatus">
|
||||
反馈:{{ formatFeedbackStatus(item.feedback_status || item.feedbackStatus) }}
|
||||
</span>
|
||||
<span>规则命中:{{ (item.rule_hits || item.ruleHits || []).length }}</span>
|
||||
<span>证据:{{ (item.evidence_refs || item.evidenceRefs || []).length }}</span>
|
||||
</div>
|
||||
<p v-if="item.review_reason || item.reviewReason" class="run-product-muted-copy">
|
||||
{{ item.review_reason || item.reviewReason }}
|
||||
</p>
|
||||
<p v-if="item.next_action || item.nextAction" class="run-product-muted-copy">
|
||||
{{ item.next_action || item.nextAction }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="run-product-inline-empty">本次运行没有整理出待复核线索。</p>
|
||||
|
||||
<div v-if="feedbackRows.length" class="run-product-feedback-panel">
|
||||
<div class="run-product-section-head compact">
|
||||
<h4>反馈样本</h4>
|
||||
<span>{{ feedbackTotal }} 条</span>
|
||||
</div>
|
||||
<ul class="run-product-feedback-list">
|
||||
<li v-for="item in feedbackRows" :key="item.feedback_id || item.feedbackId">
|
||||
<strong>{{ formatFeedbackStatus(item.feedback_type || item.feedbackType) }}</strong>
|
||||
<span>{{ item.observation_key || item.observationKey || '未关联观察' }}</span>
|
||||
<em>{{ item.actor || '系统' }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'employee_profile'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>画像快照</h4>
|
||||
@@ -180,6 +236,9 @@ const productSubtitle = computed(() => {
|
||||
if (productKind.value === 'knowledge') {
|
||||
return '展示本次知识制度整理任务的入队结果与处理范围。'
|
||||
}
|
||||
if (productKind.value === 'risk_clue') {
|
||||
return '展示本次任务归集的事实、规则命中和待人工复核线索。'
|
||||
}
|
||||
return '展示本次数字员工任务产生的结构化结果。'
|
||||
})
|
||||
const productBadge = computed(() => {
|
||||
@@ -192,8 +251,23 @@ const productBadge = computed(() => {
|
||||
if (productKind.value === 'knowledge') {
|
||||
return '知识整理'
|
||||
}
|
||||
if (productKind.value === 'risk_clue') {
|
||||
return '线索归集'
|
||||
}
|
||||
return taskLabel.value
|
||||
})
|
||||
const riskClues = computed(() =>
|
||||
Array.isArray(summary.value.risk_clues) ? summary.value.risk_clues : []
|
||||
)
|
||||
const feedbackSummary = computed(() =>
|
||||
summary.value.feedback_summary && typeof summary.value.feedback_summary === 'object'
|
||||
? summary.value.feedback_summary
|
||||
: {}
|
||||
)
|
||||
const feedbackRows = computed(() =>
|
||||
Array.isArray(feedbackSummary.value.recent) ? feedbackSummary.value.recent.slice(0, 6) : []
|
||||
)
|
||||
const feedbackTotal = computed(() => Number(feedbackSummary.value.total || feedbackRows.value.length || 0))
|
||||
const documentCount = computed(() =>
|
||||
Array.isArray(summary.value.document_ids) ? summary.value.document_ids.length : 0
|
||||
)
|
||||
@@ -223,6 +297,14 @@ const metrics = computed(() => {
|
||||
buildMetric('后台 Run ID', payload.agent_run_id || '-')
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'risk_clue') {
|
||||
return [
|
||||
buildMetric('事实', payload.fact_count ?? (payload.facts || []).length),
|
||||
buildMetric('规则命中', payload.rule_hit_count ?? (payload.rule_hits || []).length),
|
||||
buildMetric('待复核线索', payload.risk_clue_count ?? riskClues.value.length),
|
||||
buildMetric('反馈样本', feedbackTotal.value)
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -387,6 +469,31 @@ function formatRiskLevel(value) {
|
||||
return labels[String(value || '').trim()] || '未知风险'
|
||||
}
|
||||
|
||||
function formatConfidence(value) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return `${Math.round(numericValue * 100)}%`
|
||||
}
|
||||
|
||||
function formatFeedbackStatus(value) {
|
||||
const labels = {
|
||||
unreviewed: '未复核',
|
||||
pending_review: '待复核',
|
||||
confirm: '已确认',
|
||||
confirmed: '已确认',
|
||||
false_positive: '误报',
|
||||
ignore: '已忽略',
|
||||
ignored: '已忽略',
|
||||
resolve: '已处理',
|
||||
resolved: '已处理',
|
||||
comment: '备注'
|
||||
}
|
||||
const normalized = String(value || '').trim()
|
||||
return labels[normalized] || normalized || '未复核'
|
||||
}
|
||||
|
||||
function formatSignal(value) {
|
||||
const labels = {
|
||||
duplicate_invoice: '重复发票',
|
||||
|
||||
@@ -270,6 +270,10 @@
|
||||
<span>返回工作记录列表</span>
|
||||
</button>
|
||||
<div class="detail-action-group">
|
||||
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
|
||||
@@ -283,6 +287,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AuditPickerFilter from './AuditPickerFilter.vue'
|
||||
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
|
||||
@@ -317,6 +322,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
|
||||
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const runs = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
@@ -607,6 +613,14 @@ function closeWorkRecordDetail() {
|
||||
detailError.value = ''
|
||||
}
|
||||
|
||||
function openTraceCenter() {
|
||||
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
|
||||
if (!runId) {
|
||||
return
|
||||
}
|
||||
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.focusRunId,
|
||||
(runId) => {
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
@@ -60,17 +60,13 @@ const availablePercent = computed(() =>
|
||||
props.budget.map((total, index) => percent(availableAmountSeries.value[index], total))
|
||||
)
|
||||
|
||||
const yAxisMax = computed(() => {
|
||||
const maxUsage = Math.max(
|
||||
100,
|
||||
...usedPercent.value.map((value, index) => value + Number(occupiedPercent.value[index] || 0))
|
||||
)
|
||||
return Math.ceil(maxUsage / 20) * 20
|
||||
})
|
||||
const usagePercent = computed(() =>
|
||||
usedPercent.value.map((value, index) => Number((value + Number(occupiedPercent.value[index] || 0)).toFixed(2)))
|
||||
)
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
`${label}预算${currency(props.budget[index])},已使用${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%`
|
||||
`${label}预算${currency(props.budget[index])},已发生${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%,剩余${availablePercent.value[index] || 0}%`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
@@ -84,27 +80,43 @@ function buildSeriesData(percentValues, amountValues) {
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: {
|
||||
duration: prefersReducedMotion() ? 0 : 760,
|
||||
easing: 'easeOutQuart'
|
||||
duration: prefersReducedMotion() ? 0 : 820,
|
||||
easing: 'cubicOut'
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 9,
|
||||
itemHeight: 9,
|
||||
itemGap: 16,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 12,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
left: 34,
|
||||
containLabel: true
|
||||
top: 38,
|
||||
right: 88,
|
||||
bottom: 14,
|
||||
left: 58
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
shadowStyle: {
|
||||
color: 'rgba(58, 124, 165, 0.06)'
|
||||
}
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [10, 12],
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
@@ -117,45 +129,59 @@ const chartOptions = computed(() => ({
|
||||
const amount = currency(item?.data?.amount || 0)
|
||||
return `${item.marker}${item.seriesName}: ${percentValue}%(¥${amount})`
|
||||
})
|
||||
return [`${items[0]?.axisValue || ''}`, ...lines, `预算总额: ¥${currency(props.budget[index])}`].join('<br/>')
|
||||
return [
|
||||
`${items[0]?.axisValue || ''}`,
|
||||
...lines,
|
||||
`总占用率: ${usagePercent.value[index] || 0}%`,
|
||||
`预算总额: ¥${currency(props.budget[index])}`
|
||||
].join('<br/>')
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: yAxisMax.value,
|
||||
splitNumber: Math.max(1, Math.ceil(yAxisMax.value / 20)),
|
||||
max: 100,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
formatter: (value) => `${Number(value)}%`
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#edf2f7' } }
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.72)' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
inverse: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 800
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '已使用',
|
||||
name: '已发生',
|
||||
type: 'bar',
|
||||
stack: 'budgetUsage',
|
||||
data: buildSeriesData(usedPercent.value, props.used),
|
||||
barWidth: 16,
|
||||
barWidth: 20,
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#ffffff',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
formatter: ({ value }) => Number(value) >= 10 ? `${Number(value).toFixed(1)}%` : ''
|
||||
},
|
||||
itemStyle: {
|
||||
color: themeColors.value.chartPrimary,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
borderRadius: [4, 0, 0, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -163,10 +189,20 @@ const chartOptions = computed(() => ({
|
||||
type: 'bar',
|
||||
stack: 'budgetUsage',
|
||||
data: buildSeriesData(occupiedPercent.value, props.occupied),
|
||||
barWidth: 16,
|
||||
barWidth: 20,
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#ffffff',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
formatter: ({ value }) => Number(value) >= 10 ? `${Number(value).toFixed(1)}%` : ''
|
||||
},
|
||||
itemStyle: {
|
||||
color: themeColors.value.warning,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
color: themeColors.value.chartAmber || themeColors.value.warning,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -174,10 +210,22 @@ const chartOptions = computed(() => ({
|
||||
type: 'bar',
|
||||
stack: 'budgetUsage',
|
||||
data: buildSeriesData(availablePercent.value, availableAmountSeries.value),
|
||||
barWidth: 16,
|
||||
barWidth: 20,
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
distance: 10,
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 850,
|
||||
formatter: ({ dataIndex }) => `${usagePercent.value[dataIndex] || 0}%`
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#e5edf3',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
color: '#e8eef5',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -190,6 +238,6 @@ useEcharts(chartElement, chartOptions)
|
||||
.budget-trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
height: 252px;
|
||||
}
|
||||
</style>
|
||||
|
||||
154
web/src/components/charts/DigitalEmployeeDailyWorkChart.vue
Normal file
154
web/src/components/charts/DigitalEmployeeDailyWorkChart.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div ref="chartElement" class="digital-employee-daily-work-chart" role="img" :aria-label="ariaLabel"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
EChartsBarChart,
|
||||
EChartsLineChart,
|
||||
CanvasRenderer
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
rows: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const labels = computed(() => props.rows.map((item) => item.date))
|
||||
const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
|
||||
const failed = computed(() => props.rows.map((item) => Number(item.failed || 0)))
|
||||
const outputs = computed(() => props.rows.map((item) => Number(item.businessOutputs || 0)))
|
||||
const maxValue = computed(() => Math.max(...totals.value, ...failed.value, ...outputs.value, 1))
|
||||
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.rows.map((item) => (
|
||||
`${item.date}工作${item.total || 0}次,失败${item.failed || 0}次,产出${item.businessOutputs || 0}项`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationDurationUpdate: 700,
|
||||
grid: {
|
||||
top: 34,
|
||||
right: 18,
|
||||
bottom: 24,
|
||||
left: 30,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 4,
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255,255,255,.98)',
|
||||
borderColor: 'rgba(148,163,184,.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels.value,
|
||||
boundaryGap: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148,163,184,.26)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: axisMax.value,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226,232,240,.74)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '工作次数',
|
||||
type: 'bar',
|
||||
data: totals.value,
|
||||
barWidth: 18,
|
||||
itemStyle: {
|
||||
color: themeColors.value.chartPrimary,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '失败次数',
|
||||
type: 'bar',
|
||||
data: failed.value,
|
||||
barWidth: 18,
|
||||
itemStyle: {
|
||||
color: '#ef4444',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '业务产出',
|
||||
type: 'line',
|
||||
data: outputs.value,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: '#0f766e'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: '#0f766e',
|
||||
borderWidth: 2.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.digital-employee-daily-work-chart {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
</style>
|
||||
515
web/src/components/dashboard/DigitalEmployeeDashboard.vue
Normal file
515
web/src/components/dashboard/DigitalEmployeeDashboard.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<section class="digital-employee-dashboard">
|
||||
<article class="panel dashboard-card digital-work-trend-panel">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>每日工作趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<p class="card-subtitle">按天统计后台分析、整理、积累和评估任务的执行次数与业务产出。</p>
|
||||
</div>
|
||||
<span class="dashboard-window">近 {{ dashboard.windowDays }} 天</span>
|
||||
</div>
|
||||
<div v-if="loading" class="digital-dashboard-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载数字员工看板数据</span>
|
||||
</div>
|
||||
<div v-else-if="error" class="digital-dashboard-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
<DigitalEmployeeDailyWorkChart v-else :rows="dailyRows" />
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-work-day-panel">
|
||||
<div class="card-head">
|
||||
<h3>每日工作摘要 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<div class="digital-day-list">
|
||||
<div
|
||||
v-for="row in dailyRows"
|
||||
:key="row.date"
|
||||
class="digital-day-row"
|
||||
>
|
||||
<span class="digital-day-date">{{ row.date }}</span>
|
||||
<div class="digital-day-main">
|
||||
<strong>{{ row.total }} 次工作</strong>
|
||||
<small>成功 {{ row.success }},失败 {{ row.failed }},产出 {{ row.businessOutputs }} 项</small>
|
||||
</div>
|
||||
<span class="digital-day-output">{{ row.businessOutputs }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-category-panel">
|
||||
<div class="card-head">
|
||||
<h3>技能类型分布 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart
|
||||
:items="categoryLegend"
|
||||
:center-value="String(dashboard.totals.totalRuns || 0)"
|
||||
center-label="工作次数"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-task-panel">
|
||||
<div class="card-head">
|
||||
<h3>工作模块排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<BarChart
|
||||
v-if="taskRanking.length"
|
||||
:items="taskRanking"
|
||||
value-prefix=""
|
||||
value-suffix="次"
|
||||
/>
|
||||
<div v-else class="digital-dashboard-empty">
|
||||
<i class="mdi mdi-clipboard-check-outline"></i>
|
||||
<span>当前周期暂无工作记录</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-output-panel">
|
||||
<div class="card-head">
|
||||
<h3>业务产出 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<div class="digital-output-grid">
|
||||
<div v-for="item in outputItems" :key="item.label" class="digital-output-item">
|
||||
<span :style="{ color: item.color }"><i :class="item.icon"></i></span>
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small>{{ item.label }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-recent-panel">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>最近工作记录 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<p class="card-subtitle">展示最近完成或正在执行的后台任务,具体详情仍在数字员工工作记录中处理。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recentRuns.length" class="digital-recent-table">
|
||||
<div class="digital-recent-head">
|
||||
<span>时间</span>
|
||||
<span>工作模块</span>
|
||||
<span>技能类型</span>
|
||||
<span>状态</span>
|
||||
<span>摘要</span>
|
||||
<span>产出</span>
|
||||
</div>
|
||||
<div v-for="run in recentRuns" :key="run.runId" class="digital-recent-row">
|
||||
<span>{{ formatDateTime(run.startedAt) }}</span>
|
||||
<strong>{{ run.taskLabel }}</strong>
|
||||
<span>{{ run.category }}</span>
|
||||
<span class="digital-status-pill" :class="run.statusTone">{{ run.statusLabel }}</span>
|
||||
<span class="digital-recent-summary">{{ run.summary }}</span>
|
||||
<span>{{ formatRunMetrics(run.metrics) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="digital-dashboard-empty compact">
|
||||
<i class="mdi mdi-clipboard-text-clock-outline"></i>
|
||||
<span>暂无最近工作记录</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BarChart from '../charts/BarChart.vue'
|
||||
import DigitalEmployeeDailyWorkChart from '../charts/DigitalEmployeeDailyWorkChart.vue'
|
||||
import DonutChart from '../charts/DonutChart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dashboard: { type: Object, required: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: Object, default: () => null },
|
||||
dailyRows: { type: Array, default: () => [] },
|
||||
taskRanking: { type: Array, default: () => [] },
|
||||
categoryRows: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const errorMessage = computed(() => props.error?.message || '数字员工看板数据加载失败')
|
||||
const recentRuns = computed(() => props.dashboard.recentRuns || [])
|
||||
const categoryLegend = computed(() => {
|
||||
const rows = props.categoryRows
|
||||
.filter((item) => Number(item.value || item.count || 0) > 0)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
value: Number(item.value || item.count || 0),
|
||||
color: item.color,
|
||||
display: `${Number(item.value || item.count || 0)}次`
|
||||
}))
|
||||
|
||||
if (rows.length) {
|
||||
return rows
|
||||
}
|
||||
|
||||
return [{ name: '暂无数据', value: 1, display: '0次', color: '#cbd5e1' }]
|
||||
})
|
||||
|
||||
const outputItems = computed(() => {
|
||||
const totals = props.dashboard.totals || {}
|
||||
return [
|
||||
{
|
||||
label: '风险观察',
|
||||
value: Number(totals.riskObservations || 0),
|
||||
icon: 'mdi mdi-shield-search',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '风险线索',
|
||||
value: Number(totals.riskClues || 0),
|
||||
icon: 'mdi mdi-alert-decagram-outline',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '画像快照',
|
||||
value: Number(totals.profileSnapshots || 0),
|
||||
icon: 'mdi mdi-account-search-outline',
|
||||
color: '#2563eb'
|
||||
},
|
||||
{
|
||||
label: '知识文档',
|
||||
value: Number(totals.knowledgeDocuments || 0),
|
||||
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||
color: '#0f766e'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value)
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
function formatRunMetrics(metrics = {}) {
|
||||
const rows = [
|
||||
['观察', metrics.risk_observations],
|
||||
['线索', metrics.risk_clues],
|
||||
['快照', metrics.profile_snapshots],
|
||||
['文档', metrics.knowledge_documents]
|
||||
].filter(([, value]) => Number(value || 0) > 0)
|
||||
|
||||
if (!rows.length) {
|
||||
return '0项'
|
||||
}
|
||||
|
||||
return rows.map(([label, value]) => `${label}${Number(value || 0)}`).join(' / ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.digital-employee-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid #edf2f7;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-head h3 {
|
||||
min-width: 0;
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-head .mdi {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-window {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.digital-work-trend-panel,
|
||||
.digital-recent-panel {
|
||||
grid-column: span 8;
|
||||
}
|
||||
|
||||
.digital-work-day-panel {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.digital-category-panel,
|
||||
.digital-task-panel,
|
||||
.digital-output-panel {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.digital-dashboard-state,
|
||||
.digital-dashboard-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.digital-dashboard-empty.compact {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.digital-dashboard-state i,
|
||||
.digital-dashboard-empty i {
|
||||
color: #94a3b8;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.digital-dashboard-state.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.digital-day-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.digital-day-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr) 36px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.digital-day-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.digital-day-date,
|
||||
.digital-day-output {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.digital-day-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.digital-day-main strong,
|
||||
.digital-day-main small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-day-main strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.digital-day-main small {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.digital-day-output {
|
||||
text-align: right;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.digital-output-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.digital-output-item {
|
||||
min-width: 0;
|
||||
min-height: 92px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.digital-output-item > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.digital-output-item div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.digital-output-item strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.digital-output-item small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.digital-recent-table {
|
||||
display: grid;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-recent-head,
|
||||
.digital-recent-row {
|
||||
display: grid;
|
||||
grid-template-columns: 116px 150px 84px 76px minmax(0, 1fr) 150px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.digital-recent-head {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.digital-recent-row {
|
||||
min-height: 50px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.digital-recent-row strong,
|
||||
.digital-recent-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-recent-row strong {
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.digital-recent-summary {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.digital-status-pill {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: #eef2f7;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.digital-status-pill.success {
|
||||
background: rgba(var(--success-rgb), .10);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.digital-status-pill.danger {
|
||||
background: rgba(239, 68, 68, .10);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.digital-status-pill.warning {
|
||||
background: rgba(245, 158, 11, .12);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.digital-work-trend-panel,
|
||||
.digital-recent-panel,
|
||||
.digital-work-day-panel,
|
||||
.digital-category-panel,
|
||||
.digital-task-panel,
|
||||
.digital-output-panel {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.digital-recent-head,
|
||||
.digital-recent-row {
|
||||
grid-template-columns: 108px 140px 76px 72px minmax(180px, 1fr) 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -204,9 +204,10 @@ const effectItems = computed(() => {
|
||||
return [
|
||||
{ label: '规则命中', value: sourceDistribution.rule_center || 0 },
|
||||
{ label: '图谱异常', value: sourceDistribution.financial_risk_graph || 0 },
|
||||
{ label: '待复核线索', value: props.dashboard.riskClueCount || pending },
|
||||
{ label: '反馈样本', value: props.dashboard.feedbackSampleCount || 0 },
|
||||
{ label: '确认率', value: formatPercent(props.dashboard.confirmationRate) },
|
||||
{ label: '误报率', value: formatPercent(props.dashboard.falsePositiveRate) },
|
||||
{ label: '候选规则', value: props.dashboard.candidateRuleCount || 0 },
|
||||
{ label: '完成率', value: formatPercent(processedRate) }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -281,7 +281,7 @@ const emit = defineEmits([
|
||||
const isChat = computed(() => props.activeView === 'chat')
|
||||
const isOverview = computed(() => props.activeView === 'overview')
|
||||
const isWorkbench = computed(() => props.activeView === 'workbench')
|
||||
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder'].includes(props.activeView) && props.detailMode)
|
||||
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode)
|
||||
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
|
||||
const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
|
||||
@@ -447,6 +447,7 @@ const draftEnd = ref(props.customRange.end)
|
||||
const overviewDashboardOptions = [
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '风险看板', value: 'risk' },
|
||||
{ label: '数字员工看板', value: 'digitalEmployee' },
|
||||
{ label: '系统看板', value: 'system' }
|
||||
]
|
||||
const overviewDashboardValue = computed({
|
||||
|
||||
@@ -100,6 +100,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="buildFieldPipelineSections(message.result).length"
|
||||
class="risk-sim-field-pipeline"
|
||||
>
|
||||
<section
|
||||
v-for="section in buildFieldPipelineSections(message.result)"
|
||||
:key="section.key"
|
||||
>
|
||||
<header>
|
||||
<span>{{ section.title }}</span>
|
||||
<small>{{ section.description }}</small>
|
||||
</header>
|
||||
<ul>
|
||||
<li v-for="field in section.rows" :key="field.key">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<em>{{ field.source }}</em>
|
||||
<b>{{ field.value }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="buildRecognizedFieldRows(message.result).length"
|
||||
class="risk-sim-recognized-fields"
|
||||
@@ -254,6 +276,12 @@
|
||||
<p>{{ boundaryDescription }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span>测试报告</span>
|
||||
<strong>{{ testReportTitle }}</strong>
|
||||
<p>{{ testReportDescription }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span>使用字段</span>
|
||||
<div class="risk-sim-field-list">
|
||||
@@ -311,6 +339,7 @@ import {
|
||||
import {
|
||||
buildDocumentBrief,
|
||||
buildEvidenceItems as buildEvidenceItemsModel,
|
||||
buildFieldPipelineSections as buildFieldPipelineSectionsModel,
|
||||
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
||||
buildResultFields as buildResultFieldsModel,
|
||||
buildTraceItems as buildTraceItemsModel,
|
||||
@@ -419,6 +448,15 @@ const lastSimulationHint = computed(() => {
|
||||
? `最近一次仿真:命中${activeSimulationResult.value.severity_label}`
|
||||
: '最近一次仿真:未命中风险'
|
||||
})
|
||||
const testReportTitle = computed(() => latestSummary.value?.test_passed ? '已确认测试通过' : '待确认测试结论')
|
||||
const testReportDescription = computed(() => {
|
||||
const summary = latestSummary.value
|
||||
if (!summary) return '暂无测试报告。完成一次仿真后,可点击底部按钮确认测试通过。'
|
||||
if (summary.report?.summary) return summary.report.summary
|
||||
if (summary.sample?.summary) return `样例复核:${summary.sample.summary}`
|
||||
if (summary.scenario?.summary) return `场景试运行:${summary.scenario.summary}`
|
||||
return summary.test_passed ? '测试结论已保存。' : '暂无通过结论。'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -666,6 +704,10 @@ function buildRecognizedFieldRows(result) {
|
||||
return buildRecognizedFieldRowsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildFieldPipelineSections(result) {
|
||||
return buildFieldPipelineSectionsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildEvidenceItems(result) {
|
||||
return buildEvidenceItemsModel(result, fields.value)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,30 @@ export function buildRecognizedFieldRows(result, fields = []) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildFieldPipelineSections(result, fields = []) {
|
||||
const sections = [
|
||||
{
|
||||
key: 'ocr',
|
||||
title: 'OCR 原始字段',
|
||||
description: '临时附件识别得到的原始文本和结构化字段。',
|
||||
rows: buildPipelineRows(result?.ocr_raw_fields, fields, { showAttachment: true })
|
||||
},
|
||||
{
|
||||
key: 'hermes',
|
||||
title: 'Hermes 规范化字段',
|
||||
description: '合并测试意图和附件后,映射到规则字段本体。',
|
||||
rows: buildPipelineRows(result?.hermes_normalized_fields, fields)
|
||||
},
|
||||
{
|
||||
key: 'executor',
|
||||
title: '执行器实际输入',
|
||||
description: '最终交给规则执行器参与判断的字段值。',
|
||||
rows: buildPipelineRows(result?.executor_input_fields, fields, { showRequired: true })
|
||||
}
|
||||
]
|
||||
return sections.filter((section) => section.rows.length)
|
||||
}
|
||||
|
||||
export function buildEvidenceItems(result, fields = []) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
@@ -111,6 +135,26 @@ function formatRecognitionSource(source) {
|
||||
}[String(source || '').trim()] || '未标注来源'
|
||||
}
|
||||
|
||||
function buildPipelineRows(rows, fields, options = {}) {
|
||||
return (Array.isArray(rows) ? rows : []).slice(0, 16).map((field, index) => {
|
||||
const key = String(field?.key || `field-${index}`).trim()
|
||||
const sourceLabel = String(field?.source_label || '').trim() || formatRecognitionSource(field?.source)
|
||||
const suffixes = [
|
||||
options.showAttachment ? String(field?.attachment_name || '').trim() : '',
|
||||
options.showRequired && field?.required ? '判断必需' : ''
|
||||
].filter(Boolean)
|
||||
return {
|
||||
key: `${key}-${index}`,
|
||||
label: formatFieldLabel(fields.find((item) => item.key === key) || {
|
||||
key,
|
||||
label: field?.label
|
||||
}),
|
||||
source: suffixes.length ? `${sourceLabel} · ${suffixes.join(' · ')}` : sourceLabel,
|
||||
value: formatDebugValue(field?.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatDebugValue(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item ?? '')).filter(Boolean).join('、') || '-'
|
||||
if (value && typeof value === 'object') return JSON.stringify(value)
|
||||
|
||||
@@ -80,6 +80,64 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="budget-report-editor-panel">
|
||||
<div class="budget-report-section-head">
|
||||
<strong>预算构成编辑</strong>
|
||||
<span>{{ report.periodType || '预算' }} · 可直接调整</span>
|
||||
</div>
|
||||
|
||||
<div class="budget-editor-table" role="table" aria-label="预算构成编辑表">
|
||||
<div class="budget-editor-row head" role="row">
|
||||
<span role="columnheader">费用类型</span>
|
||||
<span role="columnheader">编制金额</span>
|
||||
<span role="columnheader">提醒</span>
|
||||
<span role="columnheader">告警</span>
|
||||
<span role="columnheader">风险</span>
|
||||
<span role="columnheader">预算说明</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="row in draftRows"
|
||||
:key="row.key"
|
||||
class="budget-editor-row"
|
||||
role="row"
|
||||
>
|
||||
<strong role="cell">{{ row.name }}</strong>
|
||||
<label role="cell">
|
||||
<span>金额</span>
|
||||
<input v-model.number="row.budgetAmount" type="number" min="0" step="1000" />
|
||||
</label>
|
||||
<label role="cell">
|
||||
<span>提醒</span>
|
||||
<input v-model.number="row.reminderThreshold" type="number" min="0" max="100" step="1" />
|
||||
</label>
|
||||
<label role="cell">
|
||||
<span>告警</span>
|
||||
<input v-model.number="row.alertThreshold" type="number" min="0" max="100" step="1" />
|
||||
</label>
|
||||
<label role="cell">
|
||||
<span>风险</span>
|
||||
<input v-model.number="row.riskThreshold" type="number" min="0" max="100" step="1" />
|
||||
</label>
|
||||
<textarea v-model="row.note" role="cell" rows="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="budget-editor-footer">
|
||||
<div>
|
||||
<span>当前编制总额</span>
|
||||
<strong>{{ editableTotalDisplay }}</strong>
|
||||
<small>{{ draftStatusText }}</small>
|
||||
</div>
|
||||
<button type="button" class="budget-editor-secondary" @click="applyRecommendedBudget">
|
||||
应用建议
|
||||
</button>
|
||||
<button type="button" class="budget-editor-primary" @click="generateBudgetDraft">
|
||||
生成预算草案
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section class="budget-report-action-panel">
|
||||
<div>
|
||||
<strong>编制建议</strong>
|
||||
@@ -91,7 +149,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import DonutChart from '../charts/DonutChart.vue'
|
||||
|
||||
@@ -102,6 +160,52 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const draftRows = reactive([])
|
||||
const draftStatus = ref('editing')
|
||||
|
||||
const formatAmount = (value) =>
|
||||
`¥${Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
})}`
|
||||
|
||||
function resetDraftRows() {
|
||||
draftRows.splice(
|
||||
0,
|
||||
draftRows.length,
|
||||
...((props.report.editableDraft?.rows || props.report.items || []).map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0),
|
||||
reminderThreshold: Number(item.reminderThreshold ?? 70),
|
||||
alertThreshold: Number(item.alertThreshold ?? 80),
|
||||
riskThreshold: Number(item.riskThreshold ?? 90),
|
||||
note: String(item.note || item.suggestion || '')
|
||||
})))
|
||||
)
|
||||
draftStatus.value = 'editing'
|
||||
}
|
||||
|
||||
watch(() => props.report, resetDraftRows, { immediate: true })
|
||||
|
||||
const editableTotalDisplay = computed(() =>
|
||||
formatAmount(draftRows.reduce((sum, item) => sum + Number(item.budgetAmount || 0), 0))
|
||||
)
|
||||
|
||||
const draftStatusText = computed(() =>
|
||||
draftStatus.value === 'generated'
|
||||
? '已生成本轮预算草案,后续可提交高级财务审核'
|
||||
: '调整后可生成预算草案'
|
||||
)
|
||||
|
||||
function applyRecommendedBudget() {
|
||||
resetDraftRows()
|
||||
}
|
||||
|
||||
function generateBudgetDraft() {
|
||||
draftStatus.value = 'generated'
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: '上季度预算',
|
||||
@@ -142,6 +246,7 @@ const summaryCards = computed(() => [
|
||||
.budget-report-head,
|
||||
.budget-report-main,
|
||||
.budget-report-detail-panel,
|
||||
.budget-report-editor-panel,
|
||||
.budget-report-action-panel,
|
||||
.budget-report-summary-card {
|
||||
border: 1px solid #dbe4ee;
|
||||
@@ -280,6 +385,137 @@ const summaryCards = computed(() => [
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.budget-report-editor-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.budget-editor-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.budget-editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, .7fr) minmax(118px, .95fr) repeat(3, minmax(68px, .55fr)) minmax(220px, 1.6fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.budget-editor-row.head {
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.budget-editor-row:not(.head) {
|
||||
padding: 8px;
|
||||
border: 1px solid #edf1f6;
|
||||
border-radius: 4px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.budget-editor-row > strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.budget-editor-row label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.budget-editor-row label span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.budget-editor-row input,
|
||||
.budget-editor-row textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-editor-row input {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.budget-editor-row textarea {
|
||||
min-height: 42px;
|
||||
padding: 7px 8px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.budget-editor-row input:focus,
|
||||
.budget-editor-row textarea:focus {
|
||||
border-color: rgba(58, 124, 165, .46);
|
||||
outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12));
|
||||
}
|
||||
|
||||
.budget-editor-footer {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #edf1f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-editor-footer div {
|
||||
margin-right: auto;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.budget-editor-footer span,
|
||||
.budget-editor-footer small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-editor-footer strong {
|
||||
color: #0f172a;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.budget-editor-primary,
|
||||
.budget-editor-secondary {
|
||||
min-height: 32px;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.budget-editor-primary {
|
||||
border: 0;
|
||||
background: var(--theme-primary-active);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.budget-editor-secondary {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.budget-report-expense-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -437,9 +673,30 @@ const summaryCards = computed(() => [
|
||||
@media (max-width: 900px) {
|
||||
.budget-report-summary-grid,
|
||||
.budget-report-main,
|
||||
.budget-report-expense-list {
|
||||
.budget-report-expense-list,
|
||||
.budget-editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-editor-row.head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.budget-editor-row label span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-editor-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.budget-editor-footer div {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
<template>
|
||||
<article class="detail-card panel employee-risk-profile-card">
|
||||
<div class="employee-risk-head">
|
||||
<div>
|
||||
<h3 class="detail-card-title-with-icon">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<span>风险审核画像</span>
|
||||
</h3>
|
||||
<p>{{ subtitle }}</p>
|
||||
</div>
|
||||
<span v-if="!loading && !error" :class="['profile-level-pill', levelTone]">
|
||||
{{ levelLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="employee-risk-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在读取画像快照</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="employee-risk-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="emptyReason" class="employee-risk-state">
|
||||
<i class="mdi mdi-database-search-outline"></i>
|
||||
<span>{{ emptyReason }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="employee-risk-body">
|
||||
<div class="employee-risk-summary">
|
||||
<div>
|
||||
<span>审核优先级</span>
|
||||
<strong>{{ reviewScore }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>计算窗口</span>
|
||||
<strong>{{ profile?.window_days || 90 }} 天</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>同组样本</span>
|
||||
<strong>{{ profile?.peer_group?.sample_size || 0 }} 人</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>更新时间</span>
|
||||
<strong>{{ calculatedAtText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tags.length" class="employee-risk-tags">
|
||||
<span>特征标签</span>
|
||||
<div>
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag.code"
|
||||
:class="['employee-risk-tag', tagTone(tag)]"
|
||||
:title="tag.reason"
|
||||
>
|
||||
{{ tag.display_label || tag.label }}
|
||||
<strong>{{ tag.score }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="radarDimensions.length" class="employee-risk-radar">
|
||||
<div class="employee-risk-radar-head">
|
||||
<span>行为雷达</span>
|
||||
<small>分数越高,表示该行为特征越明显。</small>
|
||||
</div>
|
||||
<div class="employee-risk-radar-layout">
|
||||
<svg class="employee-risk-radar-chart" viewBox="0 0 104 104" aria-hidden="true">
|
||||
<polygon
|
||||
v-for="ring in radarRings"
|
||||
:key="ring.scale"
|
||||
class="employee-risk-radar-ring"
|
||||
:points="ring.points"
|
||||
/>
|
||||
<line
|
||||
v-for="axis in radarAxes"
|
||||
:key="axis.key"
|
||||
class="employee-risk-radar-axis"
|
||||
x1="52"
|
||||
y1="52"
|
||||
:x2="axis.x"
|
||||
:y2="axis.y"
|
||||
/>
|
||||
<polygon class="employee-risk-radar-area" :points="radarPolygonPoints" />
|
||||
<circle
|
||||
v-for="point in radarValuePoints"
|
||||
:key="point.key"
|
||||
class="employee-risk-radar-point"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="2"
|
||||
/>
|
||||
</svg>
|
||||
<ul class="employee-risk-radar-list">
|
||||
<li v-for="item in radarDimensions" :key="item.code">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.score }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-profile-list">
|
||||
<section v-for="item in profiles" :key="item.profile_type" class="employee-risk-profile">
|
||||
<div class="employee-risk-profile-title">
|
||||
<span>{{ item.profile_label }}</span>
|
||||
<strong :class="profileLevelClass(item.level)">{{ item.score }}</strong>
|
||||
</div>
|
||||
<ul v-if="item.top_contributors?.length" class="employee-risk-evidence-list">
|
||||
<li v-for="basis in item.top_contributors.slice(0, 3)" :key="basis.code">
|
||||
<span>{{ basis.label }}</span>
|
||||
<strong>{{ formatBasisValue(basis) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="employee-risk-muted">暂无显著贡献项。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="suggestions.length" class="employee-risk-suggestions">
|
||||
<span>审核建议</span>
|
||||
<ul>
|
||||
<li v-for="item in suggestions" :key="item.type || item.message">
|
||||
{{ item.message }}
|
||||
<strong v-if="item.recommended_upper">建议上限 {{ item.recommended_upper }}{{ item.unit || '' }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'EmployeeProfileRiskCard',
|
||||
props: {
|
||||
profile: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const profiles = computed(() => Array.isArray(props.profile?.profiles) ? props.profile.profiles : [])
|
||||
const tags = computed(() => Array.isArray(props.profile?.profile_tags) ? props.profile.profile_tags.slice(0, 6) : [])
|
||||
const radarDimensions = computed(() => Array.isArray(props.profile?.radar?.dimensions) ? props.profile.radar.dimensions : [])
|
||||
const suggestions = computed(() => Array.isArray(props.profile?.review_suggestions) ? props.profile.review_suggestions : [])
|
||||
const emptyReason = computed(() => String(props.profile?.empty_reason || '').trim())
|
||||
const reviewScore = computed(() => Number(props.profile?.review_priority_score || 0))
|
||||
const level = computed(() => String(props.profile?.review_priority_level || 'normal').trim())
|
||||
const levelLabel = computed(() => String(props.profile?.review_priority_label || '正常').trim())
|
||||
const levelTone = computed(() => profileLevelClass(level.value))
|
||||
const subtitle = computed(() => {
|
||||
if (props.loading) {
|
||||
return '读取员工近期费用和流程质量画像。'
|
||||
}
|
||||
if (props.error || emptyReason.value) {
|
||||
return '当前画像不可用,审批时按单据事实继续核对。'
|
||||
}
|
||||
const windowDays = props.profile?.window_days || 90
|
||||
const sampleSize = props.profile?.peer_group?.sample_size || 0
|
||||
return `${windowDays} 天窗口,同组样本 ${sampleSize} 人,用于辅助复核费用节奏和材料质量。`
|
||||
})
|
||||
const calculatedAtText = computed(() => formatDateTime(props.profile?.calculated_at))
|
||||
const radarRings = computed(() => [0.25, 0.5, 0.75, 1].map((scale) => ({
|
||||
scale,
|
||||
points: radarDimensions.value.map((_, index) => radarPoint(index, radarDimensions.value.length, scale)).join(' ')
|
||||
})))
|
||||
const radarAxes = computed(() => radarDimensions.value.map((item, index) => ({
|
||||
key: item.code,
|
||||
...radarPointObject(index, radarDimensions.value.length, 1)
|
||||
})))
|
||||
const radarValuePoints = computed(() => radarDimensions.value.map((item, index) => ({
|
||||
key: item.code,
|
||||
...radarPointObject(index, radarDimensions.value.length, Number(item.score || 0) / 100)
|
||||
})))
|
||||
const radarPolygonPoints = computed(() => radarValuePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
|
||||
|
||||
function formatDateTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return '暂无'
|
||||
}
|
||||
const date = new Date(normalized)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return normalized.slice(0, 16)
|
||||
}
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function profileLevelClass(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized === 'escalation') {
|
||||
return 'high'
|
||||
}
|
||||
if (normalized === 'review') {
|
||||
return 'medium'
|
||||
}
|
||||
if (normalized === 'watch') {
|
||||
return 'watch'
|
||||
}
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function radarPoint(index, total, scale) {
|
||||
const point = radarPointObject(index, total, scale)
|
||||
return `${point.x},${point.y}`
|
||||
}
|
||||
|
||||
function radarPointObject(index, total, scale) {
|
||||
if (!total) {
|
||||
return { x: 52, y: 52 }
|
||||
}
|
||||
const angle = (-90 + (360 / total) * index) * (Math.PI / 180)
|
||||
const radius = 42 * Math.max(0, Math.min(1, scale))
|
||||
return {
|
||||
x: Number((52 + Math.cos(angle) * radius).toFixed(2)),
|
||||
y: Number((52 + Math.sin(angle) * radius).toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
function tagTone(tag) {
|
||||
const polarity = String(tag?.polarity || '').trim()
|
||||
if (polarity === 'positive') {
|
||||
return 'positive'
|
||||
}
|
||||
if (Number(tag?.score || 0) >= 80 || polarity === 'risk') {
|
||||
return 'risk'
|
||||
}
|
||||
return 'behavior'
|
||||
}
|
||||
|
||||
function formatBasisValue(basis) {
|
||||
const value = basis?.value
|
||||
const unit = String(basis?.unit || '').trim()
|
||||
if (value == null || value === '') {
|
||||
return basis?.score != null ? `${basis.score}分` : ''
|
||||
}
|
||||
if (unit === '占比') {
|
||||
const ratio = Number(value)
|
||||
return Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : String(value)
|
||||
}
|
||||
return `${value}${unit && unit !== '比例' ? unit : ''}`
|
||||
}
|
||||
|
||||
return {
|
||||
calculatedAtText,
|
||||
emptyReason,
|
||||
formatBasisValue,
|
||||
levelLabel,
|
||||
levelTone,
|
||||
profileLevelClass,
|
||||
profiles,
|
||||
radarAxes,
|
||||
radarDimensions,
|
||||
radarPolygonPoints,
|
||||
radarRings,
|
||||
radarValuePoints,
|
||||
reviewScore,
|
||||
subtitle,
|
||||
suggestions,
|
||||
tags,
|
||||
tagTone
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-risk-profile-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.employee-risk-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.employee-risk-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-level-pill {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-level-pill.normal,
|
||||
.employee-risk-profile-title strong.normal {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.profile-level-pill.watch,
|
||||
.employee-risk-profile-title strong.watch {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.profile-level-pill.medium,
|
||||
.employee-risk-profile-title strong.medium {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.profile-level-pill.high,
|
||||
.employee-risk-profile-title strong.high {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-state {
|
||||
min-height: 78px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.employee-risk-state.error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.employee-risk-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-summary > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-summary span,
|
||||
.employee-risk-suggestions > span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-summary strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-tags {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-tags > span,
|
||||
.employee-risk-radar-head span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-tags > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.employee-risk-tag {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-tag strong {
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-tag.risk {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-tag.behavior {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.employee-risk-tag.positive {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.employee-risk-radar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-radar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-radar-head small {
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.employee-risk-radar-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-risk-radar-chart {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
}
|
||||
|
||||
.employee-risk-radar-ring {
|
||||
fill: none;
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 0.75;
|
||||
}
|
||||
|
||||
.employee-risk-radar-axis {
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 0.75;
|
||||
}
|
||||
|
||||
.employee-risk-radar-area {
|
||||
fill: rgba(37, 99, 235, 0.16);
|
||||
stroke: #2563eb;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.employee-risk-radar-point {
|
||||
fill: #2563eb;
|
||||
}
|
||||
|
||||
.employee-risk-radar-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.employee-risk-radar-list li {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.employee-risk-radar-list strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.employee-risk-profile-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-profile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong {
|
||||
min-width: 36px;
|
||||
height: 22px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list,
|
||||
.employee-risk-suggestions ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list li,
|
||||
.employee-risk-suggestions li {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list strong,
|
||||
.employee-risk-suggestions strong {
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-muted {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.employee-risk-suggestions {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.employee-risk-summary,
|
||||
.employee-risk-profile-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-radar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-radar-chart {
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
722
web/src/components/travel/StageRiskAdviceCard.vue
Normal file
722
web/src/components/travel/StageRiskAdviceCard.vue
Normal file
@@ -0,0 +1,722 @@
|
||||
<template>
|
||||
<article :class="['detail-card panel employee-risk-profile-card', `is-${decisionTone}`]">
|
||||
<div class="detail-card-head employee-risk-head">
|
||||
<div class="employee-risk-title-wrap">
|
||||
<h3 class="detail-card-title-with-icon">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<span>{{ stageTitle }}</span>
|
||||
</h3>
|
||||
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-body">
|
||||
<section :class="['employee-risk-ai-note', decisionTone]">
|
||||
<div class="employee-risk-ai-main">
|
||||
<span>AI 审核建议</span>
|
||||
<strong>{{ decisionTitle }}</strong>
|
||||
<p>{{ decisionDescription }}</p>
|
||||
</div>
|
||||
<div class="employee-risk-action">
|
||||
<span>建议动作</span>
|
||||
<strong :class="decisionTone">{{ decisionAction }}</strong>
|
||||
</div>
|
||||
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
|
||||
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="employee-risk-profile-section">
|
||||
<div class="employee-risk-section-head">
|
||||
<span>{{ stageBasisTitle }}</span>
|
||||
<small>{{ stageBasisHint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-profile-list">
|
||||
<section
|
||||
v-for="item in compactEvidenceItems"
|
||||
:key="item.code"
|
||||
:class="['employee-risk-profile', item.tone]"
|
||||
>
|
||||
<div class="employee-risk-profile-title">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="item.tone">{{ item.status }}</strong>
|
||||
</div>
|
||||
<ul v-if="item.evidence.length" class="employee-risk-evidence-list">
|
||||
<li v-for="basis in item.evidence" :key="basis">
|
||||
{{ basis }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="employee-risk-muted">暂无显著贡献项。</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'StageRiskAdviceCard',
|
||||
props: {
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
expenseItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
aiAdvice: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isApplicationDocument: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const requestModel = computed(() => props.request || {})
|
||||
const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : [])
|
||||
const currentRiskCards = computed(() =>
|
||||
(Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : [])
|
||||
.filter((card) => matchesCurrentStage(card, props.isApplicationDocument))
|
||||
.filter((card) => ['medium', 'high'].includes(normalizeTone(card?.tone)))
|
||||
)
|
||||
const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high'))
|
||||
const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium'))
|
||||
const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value))
|
||||
const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument))
|
||||
const decisionTone = computed(() => {
|
||||
if (highRiskCards.value.length) {
|
||||
return 'high'
|
||||
}
|
||||
if (mediumRiskCards.value.length || materialIssues.value.length || sceneIssues.value.length) {
|
||||
return 'medium'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
|
||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请环节风险依据' : '报销环节风险依据')
|
||||
const stageBasisHint = computed(() => (
|
||||
props.isApplicationDocument
|
||||
? '只展示本次申请可能影响预算和审批的风险。'
|
||||
: '只展示本次报销可能影响票据、金额和付款的风险。'
|
||||
))
|
||||
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
|
||||
const decisionAction = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).action)
|
||||
const decisionBadgeLabel = computed(() => {
|
||||
if (decisionTone.value === 'high') {
|
||||
return '高风险'
|
||||
}
|
||||
if (decisionTone.value === 'medium') {
|
||||
return '需关注'
|
||||
}
|
||||
return '可审批'
|
||||
})
|
||||
const decisionDescription = computed(() => {
|
||||
const riskCount = currentRiskCards.value.length
|
||||
if (riskCount) {
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
|
||||
}
|
||||
if (materialIssues.value.length || sceneIssues.value.length) {
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
|
||||
}
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
|
||||
})
|
||||
const adviceItems = computed(() => {
|
||||
const fromRiskCards = currentRiskCards.value
|
||||
.map((card) => String(card?.suggestion || card?.risk || '').trim())
|
||||
.filter(Boolean)
|
||||
return uniqueTexts(fromRiskCards.length ? fromRiskCards : resolveDecision(decisionTone.value, props.isApplicationDocument).advice).slice(0, 4)
|
||||
})
|
||||
const compactAdviceItems = computed(() => adviceItems.value.slice(0, 2))
|
||||
|
||||
const stageEvidenceItems = computed(() => (
|
||||
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
|
||||
))
|
||||
const compactEvidenceItems = computed(() => {
|
||||
const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item))
|
||||
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
|
||||
return sourceItems.slice(0, 3).map((item) => ({
|
||||
...item,
|
||||
evidence: item.evidence.slice(0, 2)
|
||||
}))
|
||||
})
|
||||
|
||||
function buildApplicationEvidence() {
|
||||
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
|
||||
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标/.test(cardText(card)))
|
||||
return [
|
||||
evidenceItem('apply_amount', '申请金额与科目', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
|
||||
`申请科目:${displayValue(requestModel.value.typeLabel || requestModel.value.sceneLabel, '待确认')}`,
|
||||
`申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
|
||||
...riskTexts(amountCards)
|
||||
]),
|
||||
evidenceItem('apply_budget', '预算影响', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', (
|
||||
budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。']
|
||||
)),
|
||||
evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [
|
||||
`申请事由:${displayValue(requestModel.value.reason, '待补充')}`,
|
||||
`申请目的地/发生地点:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
|
||||
`申请时间:${displayValue(requestModel.value.period || requestModel.value.occurredDisplay, '待补充')}`,
|
||||
...sceneIssues.value.map((item) => `当前缺少:${item}`)
|
||||
]),
|
||||
evidenceItem('apply_risk', '申请规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
|
||||
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['申请环节未命中中高风险规则。']
|
||||
))
|
||||
]
|
||||
}
|
||||
|
||||
function buildReimbursementEvidence() {
|
||||
const attachmentCards = currentRiskCards.value.filter((card) => /附件|票据|发票|OCR|识别|单据/.test(cardText(card)))
|
||||
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标|不一致/.test(cardText(card)))
|
||||
const routeCards = currentRiskCards.value.filter((card) => /城市|行程|住宿|交通|出差|地点|日期|时间/.test(cardText(card)))
|
||||
const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated)
|
||||
const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length
|
||||
return [
|
||||
evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [
|
||||
`需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`,
|
||||
...materialIssues.value.slice(0, 3),
|
||||
...riskTexts(attachmentCards)
|
||||
]),
|
||||
evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
|
||||
`报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
|
||||
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}。`,
|
||||
...riskTexts(amountCards)
|
||||
]),
|
||||
evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [
|
||||
`报销事由:${displayValue(requestModel.value.reason, '待补充')}`,
|
||||
`报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
|
||||
...sceneIssues.value.map((item) => `当前缺少:${item}`),
|
||||
...riskTexts(routeCards)
|
||||
]),
|
||||
evidenceItem('reimburse_risk', '报销规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
|
||||
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['报销环节未命中中高风险规则。']
|
||||
))
|
||||
]
|
||||
}
|
||||
|
||||
function evidenceItem(code, label, status, tone, evidence) {
|
||||
return {
|
||||
code,
|
||||
label,
|
||||
status,
|
||||
tone,
|
||||
evidence: uniqueTexts(evidence).filter(Boolean).slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adviceItems,
|
||||
compactAdviceItems,
|
||||
compactEvidenceItems,
|
||||
decisionBadgeLabel,
|
||||
decisionTone,
|
||||
decisionDescription,
|
||||
decisionAction,
|
||||
decisionTitle,
|
||||
stageBasisHint,
|
||||
stageBasisTitle,
|
||||
stageEvidenceItems,
|
||||
stageTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDecision(tone, isApplicationDocument) {
|
||||
const subject = isApplicationDocument ? '申请' : '报销'
|
||||
const map = {
|
||||
normal: {
|
||||
title: `当前${subject}未发现中高风险阻断项`,
|
||||
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`,
|
||||
advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。']
|
||||
},
|
||||
medium: {
|
||||
title: `当前${subject}存在中风险,建议核对后处理`,
|
||||
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。',
|
||||
advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。']
|
||||
},
|
||||
high: {
|
||||
title: `当前${subject}存在高风险,不建议直接通过`,
|
||||
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。',
|
||||
advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。']
|
||||
}
|
||||
}
|
||||
return map[tone] || map.normal
|
||||
}
|
||||
|
||||
function isAbnormalEvidence(item) {
|
||||
const tone = normalizeTone(item?.tone)
|
||||
const status = String(item?.status || '').trim()
|
||||
if (tone === 'medium' || tone === 'high') {
|
||||
return true
|
||||
}
|
||||
return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status)
|
||||
}
|
||||
|
||||
function matchesCurrentStage(card, isApplicationDocument) {
|
||||
const businessStage = resolveCardBusinessStage(card)
|
||||
if (businessStage) {
|
||||
return isApplicationDocument
|
||||
? businessStage === 'expense_application'
|
||||
: businessStage === 'reimbursement'
|
||||
}
|
||||
|
||||
const text = cardText(card)
|
||||
if (isApplicationDocument) {
|
||||
return !/报销|附件|单据|发票|票据|OCR|识别|付款|支付/.test(text) || /申请|预算|额度|事前|预估|审批/.test(text)
|
||||
}
|
||||
return !/申请环节|事前申请|预算申请/.test(text)
|
||||
}
|
||||
|
||||
function resolveCardBusinessStage(card = {}) {
|
||||
const candidates = [
|
||||
card.businessStage,
|
||||
card.business_stage,
|
||||
card.controlStage,
|
||||
card.control_stage
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const stage = normalizeBusinessStage(candidate)
|
||||
if (stage) {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeBusinessStage(value) {
|
||||
const stage = String(value || '').trim().toLowerCase()
|
||||
if ([
|
||||
'expense_application',
|
||||
'application',
|
||||
'apply',
|
||||
'pre_apply',
|
||||
'pre_application',
|
||||
'budget_application'
|
||||
].includes(stage)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
if ([
|
||||
'reimbursement',
|
||||
'expense_reimbursement',
|
||||
'claim',
|
||||
'expense_claim',
|
||||
'expense_report'
|
||||
].includes(stage)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveReimbursementMaterialIssues(items) {
|
||||
return items
|
||||
.filter((item) => !item?.isSystemGenerated && !String(item?.invoiceId || '').trim())
|
||||
.map((item) => `未上传票据:${item.name || item.category || item.desc || '未命名明细'}`)
|
||||
}
|
||||
|
||||
function resolveSceneIssues(request, items, isApplicationDocument) {
|
||||
const missing = []
|
||||
if (isMissing(request.reason)) {
|
||||
missing.push(isApplicationDocument ? '申请事由' : '报销事由')
|
||||
}
|
||||
if (isMissing(request.location) || isMissing(request.sceneTarget)) {
|
||||
missing.push(isApplicationDocument ? '申请地点/目的地' : '报销地点/目的地')
|
||||
}
|
||||
if (isMissing(request.period) || isMissing(request.occurredDisplay)) {
|
||||
missing.push(isApplicationDocument ? '申请发生时间' : '报销发生时间')
|
||||
}
|
||||
if (!isApplicationDocument) {
|
||||
const itemMissingReasonCount = items.filter((item) => isMissing(item?.itemReason || item?.desc)).length
|
||||
if (itemMissingReasonCount) {
|
||||
missing.push(`${itemMissingReasonCount} 条报销明细缺少事由`)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = String(value || '').trim().toLowerCase()
|
||||
if (tone === 'high') return 'high'
|
||||
if (tone === 'medium') return 'medium'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function highestTone(cards, fallback = 'normal') {
|
||||
if (!cards.length) return fallback
|
||||
if (cards.some((card) => normalizeTone(card?.tone) === 'high')) return 'high'
|
||||
if (cards.some((card) => normalizeTone(card?.tone) === 'medium')) return 'medium'
|
||||
return fallback
|
||||
}
|
||||
|
||||
function riskTexts(cards) {
|
||||
return cards
|
||||
.map((card) => String(card?.risk || card?.summary || card?.title || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
function cardText(card) {
|
||||
return [
|
||||
card?.label,
|
||||
card?.title,
|
||||
card?.risk,
|
||||
card?.summary,
|
||||
card?.suggestion,
|
||||
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
|
||||
]
|
||||
.map((item) => String(item || '').trim())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function displayValue(value, fallback) {
|
||||
const text = String(value || '').trim()
|
||||
return isMissing(text) ? fallback : text
|
||||
}
|
||||
|
||||
function isMissing(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '暂无', '无', 'null', 'undefined'].includes(text)
|
||||
}
|
||||
|
||||
function totalItemAmount(items) {
|
||||
return items.reduce((sum, item) => sum + safeNumber(item?.itemAmount), 0)
|
||||
}
|
||||
|
||||
function safeNumber(value) {
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) ? amount : 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return `${safeNumber(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||
}
|
||||
|
||||
function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-risk-profile-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.employee-risk-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.detail-card-head h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill {
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.normal {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.normal {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.medium {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.high {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note > span,
|
||||
.employee-risk-ai-main > span,
|
||||
.employee-risk-section-head span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note strong,
|
||||
.employee-risk-ai-main strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 38%);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-ai-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.employee-risk-action {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-action span {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-action strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.medium {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list p {
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid #cbd5e1;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-profile-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head small {
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.employee-risk-profile-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.employee-risk-profile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 142px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.employee-risk-profile.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fffaf4;
|
||||
}
|
||||
|
||||
.employee-risk-profile.high {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 22px;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong {
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
flex: 0 0 48px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list li {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-muted {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user