feat: 新增预算中心本体与风险规则评分回填

后端新增预算本体解析模块和风险规则评分回填服务,优化规则
生成本体对齐和提示词构建,增强费用类型关键词和本体验证,
完善报销查询和审计接口,前端预算中心页面增加对话框和本
体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 12:16:20 +08:00
parent 0e861d8fa6
commit e1e515ecae
53 changed files with 4350 additions and 921 deletions

BIN
web/UI/编辑预算.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -0,0 +1,349 @@
.budget-dialog-backdrop,
.budget-dialog-backdrop * {
box-sizing: border-box;
}
.budget-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(15, 23, 42, .52);
backdrop-filter: blur(1px);
}
.budget-edit-dialog {
width: min(1024px, calc(100vw - 48px));
max-height: calc(100vh - 56px);
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
border-radius: 8px;
background: #fff;
box-shadow: 0 24px 72px rgba(15, 23, 42, .28);
overflow: hidden;
}
.budget-edit-head {
min-height: 56px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f6;
}
.budget-edit-head strong {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.budget-dialog-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 20px;
cursor: pointer;
transition: background 160ms ease, color 160ms ease;
}
.budget-dialog-close:hover {
background: #f1f5f9;
color: #0f172a;
}
.budget-edit-body {
min-height: 0;
padding: 18px 24px 16px;
overflow: auto;
}
.budget-edit-section + .budget-edit-section {
margin-top: 18px;
}
.budget-edit-section h3 {
margin: 0 0 12px;
color: #111827;
font-size: 15px;
line-height: 1.35;
font-weight: 800;
}
.budget-edit-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 28px;
}
.budget-edit-form-grid label,
.budget-edit-textarea {
min-width: 0;
display: grid;
gap: 7px;
color: #334155;
font-size: 13px;
font-weight: 750;
}
.budget-edit-form-grid label.required > span::after {
content: "*";
margin-left: 3px;
color: #ef4444;
}
.budget-edit-form-grid select,
.budget-edit-textarea textarea,
.budget-edit-table input,
.budget-edit-table select {
width: 100%;
border: 1px solid #dbe4ee;
border-radius: 6px;
background: #fff;
color: #111827;
font-size: 14px;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.budget-edit-form-grid select {
height: 38px;
padding: 0 34px 0 12px;
}
.budget-edit-form-grid select:focus,
.budget-edit-textarea textarea:focus,
.budget-edit-table input:focus,
.budget-edit-table select:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
}
.budget-edit-textarea {
position: relative;
margin-top: 14px;
}
.budget-edit-textarea textarea {
min-height: 86px;
resize: none;
padding: 12px 14px 24px;
line-height: 1.6;
}
.budget-edit-textarea em {
position: absolute;
right: 12px;
bottom: 9px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
font-weight: 500;
}
.budget-edit-table-wrap {
border: 1px solid #edf1f6;
border-radius: 8px;
overflow-x: auto;
}
.budget-edit-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
table-layout: fixed;
}
.budget-edit-table th,
.budget-edit-table td {
height: 48px;
padding: 8px 10px;
border-right: 1px solid #edf1f6;
border-bottom: 1px solid #edf1f6;
text-align: center;
vertical-align: middle;
}
.budget-edit-table th:last-child,
.budget-edit-table td:last-child {
border-right: 0;
}
.budget-edit-table tbody tr:last-child td {
border-bottom: 0;
}
.budget-edit-table th {
background: #fbfcfe;
color: #334155;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.budget-edit-table th i {
color: #ef4444;
font-style: normal;
}
.budget-edit-table th:nth-child(1) { width: 180px; }
.budget-edit-table th:nth-child(2) { width: 168px; }
.budget-edit-table th:nth-child(3) { width: 120px; }
.budget-edit-table th:nth-child(4) { width: 120px; }
.budget-edit-table th:nth-child(6) { width: 68px; }
.budget-edit-table input,
.budget-edit-table select {
height: 34px;
padding: 0 10px;
text-align: center;
}
.budget-edit-table td:nth-child(5) input {
text-align: left;
}
.budget-row-delete {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 18px;
cursor: pointer;
}
.budget-row-delete:hover {
background: #fef2f2;
color: #dc2626;
}
.budget-add-row-btn {
height: 28px;
margin-top: 8px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(16, 185, 129, .42);
border-radius: 6px;
background: #fff;
color: #059669;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.budget-edit-total {
height: 42px;
margin-top: 8px;
padding: 0 14px;
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
border: 1px solid #edf1f6;
border-radius: 8px;
background: #fbfcfe;
}
.budget-edit-total span,
.budget-edit-total strong {
color: #111827;
font-size: 14px;
font-weight: 800;
}
.budget-edit-foot {
padding: 18px 24px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
border-top: 1px solid #edf1f6;
background: #fff;
}
.budget-edit-foot button {
height: 40px;
min-width: 156px;
border-radius: 7px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.budget-edit-cancel {
border: 1px solid #dbe4ee;
background: #fff;
color: #334155;
}
.budget-edit-draft {
border: 1px solid #10b981;
background: #fff;
color: #059669;
}
.budget-edit-publish {
border: 0;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
box-shadow: 0 10px 24px rgba(5, 150, 105, .20);
}
.budget-dialog-fade-enter-active,
.budget-dialog-fade-leave-active {
transition: opacity 180ms ease;
}
.budget-dialog-fade-enter-active .budget-edit-dialog,
.budget-dialog-fade-leave-active .budget-edit-dialog {
transition: transform 200ms ease, opacity 180ms ease;
}
.budget-dialog-fade-enter-from,
.budget-dialog-fade-leave-to {
opacity: 0;
}
.budget-dialog-fade-enter-from .budget-edit-dialog,
.budget-dialog-fade-leave-to .budget-edit-dialog {
opacity: 0;
transform: translateY(12px);
}
@media (max-width: 860px) {
.budget-dialog-backdrop {
align-items: flex-start;
padding: 18px;
}
.budget-edit-dialog {
width: 100%;
max-height: calc(100vh - 36px);
}
.budget-edit-form-grid {
grid-template-columns: 1fr;
}
.budget-edit-foot {
flex-direction: column;
gap: 10px;
}
.budget-edit-foot button {
width: 100%;
}
}

View File

@@ -5,138 +5,245 @@
color: #1f2937;
}
.budget-local-head {
min-height: 34px;
display: flex;
align-items: center;
}
.budget-local-head h2 {
margin: 0;
color: #111827;
font-size: 24px;
line-height: 1.2;
font-weight: 800;
}
.budget-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e5eaf1;
border-radius: 8px;
background: #fff;
overflow: hidden;
gap: 12px;
}
.budget-summary-card {
min-height: 118px;
padding: 22px 28px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 18px;
border-right: 1px solid #edf1f6;
--accent: #10b981;
position: relative;
min-height: 112px;
padding: 12px 14px 10px;
display: flex;
flex-direction: column;
border: 1px solid #dbe4ee;
border-left: 3px solid var(--accent);
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.budget-summary-card:last-child {
border-right: 0;
.budget-summary-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
transform: translateY(-1px);
}
.budget-summary-card.green {
--accent: #10b981;
}
.budget-summary-card.blue {
--accent: #3b82f6;
}
.budget-summary-card.orange {
--accent: #f59e0b;
}
.budget-summary-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
min-width: 0;
}
.summary-icon {
width: 54px;
height: 54px;
border-radius: 50%;
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
font-size: 30px;
}
.summary-icon.green {
background: #e8f7ef;
color: #07965f;
}
.summary-icon.blue {
background: #edf4ff;
color: #2f7fd7;
}
.summary-icon.orange {
background: #fff4e5;
color: #df9300;
}
.budget-summary-card span:not(.summary-icon) {
display: block;
color: #1f2937;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 14px;
font-weight: 700;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.budget-summary-card strong {
.budget-summary-card .summary-label {
display: block;
margin-top: 8px;
color: #111827;
font-size: 24px;
line-height: 1;
min-width: 0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-value {
display: block;
min-height: 22px;
margin-bottom: 6px;
color: #0f172a;
font-size: clamp(16px, 1.2vw, 20px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
letter-spacing: 0;
}
.summary-comparison-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
min-width: 0;
flex-wrap: wrap;
}
.comparison-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 1.45;
font-weight: 700;
white-space: nowrap;
}
.budget-summary-card em {
display: block;
margin-top: 10px;
color: #8a94a6;
font-size: 13px;
.comparison-pill b {
color: inherit;
font-size: 11px;
font-weight: 600;
}
.comparison-pill em {
font-style: normal;
font-variant-numeric: tabular-nums;
}
.comparison-pill i {
font-size: 11px;
}
.comparison-pill.up {
background: rgba(22, 163, 74, .08);
color: #16a34a;
}
.comparison-pill.down {
background: rgba(239, 68, 68, .08);
color: #dc2626;
}
.budget-filter-bar {
min-height: 62px;
border: 1px solid #e5eaf1;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 12px 18px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 22px;
justify-content: space-between;
gap: 16px;
}
.budget-filter-set,
.budget-action-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.budget-filter-bar label {
display: inline-flex;
align-items: center;
gap: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.budget-filter-bar select,
.budget-table-foot select {
height: 34px;
min-width: 150px;
border: 1px solid #dbe2ec;
border-radius: 5px;
.budget-filter-bar select {
min-height: 38px;
min-width: 128px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #1f2937;
padding: 0 34px 0 12px;
color: #334155;
padding: 0 34px 0 14px;
font-size: 14px;
font-weight: 750;
transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease;
}
.budget-filter-bar select:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.budget-filter-bar select:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .14);
outline: none;
}
.budget-primary-btn {
margin-left: auto;
height: 36px;
min-height: 40px;
border: 0;
border-radius: 5px;
background: #0aa66f;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 10px 24px rgba(5, 150, 105, .2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.budget-primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, .24);
filter: saturate(1.02);
}
.budget-ghost-btn {
min-height: 38px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.budget-ghost-btn:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.budget-work-grid {
@@ -240,7 +347,7 @@
border-right: 1px solid #edf1f6;
color: #273142;
font-size: 14px;
text-align: left;
text-align: center;
white-space: nowrap;
}
@@ -259,6 +366,7 @@
width: 96px;
display: grid;
gap: 6px;
margin: 0 auto;
}
.budget-rate span {
@@ -304,6 +412,7 @@
.budget-row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
@@ -324,24 +433,84 @@
gap: 10px;
}
.budget-table-foot button {
.budget-page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.budget-pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.budget-pager button {
width: 32px;
height: 32px;
border: 1px solid #dbe2ec;
border-radius: 5px;
background: #fff;
color: #64748b;
}
.budget-table-foot button.active {
border-color: #10a873;
color: #10a873;
font-weight: 800;
}
.budget-table-foot span {
color: #4b5563;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.budget-pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.budget-pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.budget-pager button:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.budget-page-size {
min-height: 38px;
min-width: 112px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease;
}
.budget-page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.budget-page-size select {
appearance: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
cursor: pointer;
}
.budget-bottom-grid {
@@ -448,6 +617,32 @@
text-align: right;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1280px) {
.budget-summary-grid,
.budget-bottom-grid {
@@ -480,7 +675,24 @@
.budget-filter-bar label,
.budget-filter-bar select,
.budget-primary-btn {
.budget-filter-set,
.budget-action-set,
.budget-primary-btn,
.budget-ghost-btn {
width: 100%;
}
.budget-filter-bar label {
justify-content: space-between;
}
.budget-table-foot {
justify-content: flex-start;
flex-wrap: wrap;
}
.budget-pager,
.budget-page-size {
width: 100%;
}

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'audit', 'employees', 'policies', 'logs', 'settings']
export const navItems = [
{
@@ -38,21 +38,13 @@ export const navItems = [
title: '预算中心',
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
},
{
id: 'policies',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。'
},
{
id: 'audit',
label: '任务规则中心',
navHint: '查看和管理任务规则配置',
navHint: '查看和管理规则配置',
icon: icons.skill,
title: '任务规则中心',
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
desc: '集中管理财务规则、风险规则、技能与外部 MCP 服务。'
},
{
id: 'employees',
@@ -62,6 +54,14 @@ export const navItems = [
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
},
{
id: 'policies',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。'
},
{
id: 'logs',
label: '日志管理',

View File

@@ -17,9 +17,11 @@ const EXPENSE_TYPE_LABELS = {
ride_ticket: '乘车',
travel_allowance: '出差补贴',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
meeting: '会务费',
training: '培训费',
software: '软件服务费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',

View File

@@ -5,7 +5,7 @@ export const icons = {
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),

View File

@@ -33,13 +33,17 @@ function normalizedRoleCodes(user) {
: []
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return Boolean(user?.isAdmin)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')

View File

@@ -0,0 +1,199 @@
export const BUDGET_ONTOLOGY_FIELDS = [
{
key: 'budget_period',
label: '预算周期',
scope: 'budget_header',
required: true,
aliases: ['预算周期', '预算期间', '年度', '季度', '月份']
},
{
key: 'department',
label: '所属部门',
scope: 'budget_header',
required: true,
aliases: ['所属部门', '预算部门', '部门']
},
{
key: 'cost_center',
label: '成本中心',
scope: 'budget_header',
required: true,
aliases: ['成本中心', '成本中心编码']
},
{
key: 'budget_owner',
label: '预算负责人',
scope: 'budget_header',
required: true,
aliases: ['预算负责人', '负责人', '编制人']
},
{
key: 'budget_version',
label: '预算版本',
scope: 'budget_header',
required: true,
aliases: ['预算版本', '版本']
},
{
key: 'budget_status',
label: '预算状态',
scope: 'budget_header',
required: true,
aliases: ['预算状态', '状态']
},
{
key: 'budget_description',
label: '预算说明',
scope: 'budget_header',
required: false,
aliases: ['预算说明', '编制说明', '说明']
},
{
key: 'budget_subject',
label: '预算科目',
scope: 'budget_detail',
required: true,
aliases: ['预算科目', '费用类型', '费用科目']
},
{
key: 'budget_amount',
label: '预算金额',
scope: 'budget_detail',
required: true,
aliases: ['预算金额', '预算额度', '预算总额']
},
{
key: 'reserved_amount',
label: '已占用',
scope: 'budget_execution',
required: false,
aliases: ['已占用', '已预占', '占用金额']
},
{
key: 'consumed_amount',
label: '已发生',
scope: 'budget_execution',
required: false,
aliases: ['已发生', '已核销', '已消耗', '已使用']
},
{
key: 'available_amount',
label: '剩余可用',
scope: 'budget_execution',
required: false,
aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算']
},
{
key: 'warning_threshold',
label: '预警线',
scope: 'budget_control',
required: true,
aliases: ['预警线', '预警阈值', '预算预警']
},
{
key: 'control_action',
label: '控制动作',
scope: 'budget_control',
required: true,
aliases: ['控制动作', '管控动作', '超预算控制']
},
{
key: 'budget_remark',
label: '备注',
scope: 'budget_detail',
required: false,
aliases: ['备注', '说明']
}
]
export const BUDGET_FIELD_KEYS = Object.freeze(
BUDGET_ONTOLOGY_FIELDS.reduce((result, field) => {
result[field.key] = field.key
return result
}, {})
)
export const BUDGET_STATUS_OPTIONS = ['编制中', '已发布', '已冻结']
export const BUDGET_WARNING_OPTIONS = ['60%', '70%', '80%', '90%']
export const BUDGET_CONTROL_ACTION_OPTIONS = ['正常', '提醒', '管控']
export const BUDGET_YEAR_OPTIONS = ['2026', '2027', '2028']
export const BUDGET_QUARTER_OPTIONS = ['Q1', 'Q2', 'Q3', 'Q4']
export const BUDGET_EXPENSE_TYPE_OPTIONS = Object.freeze([
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'marketing', label: '市场推广费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'software', label: '软件服务费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
])
const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze(
BUDGET_EXPENSE_TYPE_OPTIONS.reduce((result, item) => {
result[item.value] = item
return result
}, {})
)
export function resolveBudgetExpenseTypeLabel(code, fallback = '') {
return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback
}
export function formatBudgetPeriod(year, quarter) {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
? String(quarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
return `${normalizedYear}${normalizedQuarter}`
}
export function buildBudgetOntologyContext({ form = {}, rows = [], departments = [] } = {}) {
const department = departments.find((item) => item.code === form.departmentCode) || {}
const budgetYear =
String(form.budgetYear || '').replace(/[^\d]/g, '') ||
String(form.budgetPeriod || '').replace(/[^\d]/g, '').slice(0, 4) ||
'2026'
const budgetQuarter = BUDGET_QUARTER_OPTIONS.includes(String(form.budgetQuarter || '').trim())
? String(form.budgetQuarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
const budgetPeriod = form.budgetYear || form.budgetQuarter
? formatBudgetPeriod(budgetYear, budgetQuarter)
: form.budgetPeriod || formatBudgetPeriod(budgetYear, budgetQuarter)
return {
document_type: 'budget_plan',
entry_source: 'budget_center',
conversation_scenario: 'budget',
budget_fields: BUDGET_ONTOLOGY_FIELDS,
budget_header: {
budget_period: budgetPeriod,
budget_year: budgetYear,
budget_quarter: budgetQuarter,
department: department.name || '',
department_code: form.departmentCode || '',
cost_center: form.costCenter || department.costCenter || '',
budget_owner: form.budgetOwner || '',
budget_version: form.budgetVersion || '',
budget_status: form.budgetStatus || '',
budget_description: form.budgetDescription || ''
},
budget_details: rows.map((row) => {
const code = String(row.budgetSubjectCode || '').trim()
const option = BUDGET_EXPENSE_TYPE_BY_CODE[code]
const label = option?.label || row.budgetSubject || ''
return {
budget_subject: label,
budget_subject_code: option?.value || code,
expense_type: option?.value || code,
expense_type_label: label,
budget_amount: row.budgetAmount || '',
warning_threshold: row.warningThreshold || '',
control_action: row.controlAction || '',
budget_remark: row.budgetRemark || ''
}
})
}
}

View File

@@ -5,8 +5,10 @@ const EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
entertainment: '业务招待费',
meeting: '会务费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'

View File

@@ -27,8 +27,10 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'

View File

@@ -261,7 +261,7 @@
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间上线状态和审核历史</p>
<p>这条规则的业务域风险等级创建时间上线状态和最近操作</p>
</div>
</div>
<div class="json-risk-meta-grid">
@@ -273,6 +273,10 @@
<span class="json-risk-meta-label">适用场景</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务环节</span>
<span class="json-risk-meta-value">{{ selectedSkill.businessStageLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">风险等级</span>
<span class="json-risk-meta-value">
@@ -288,17 +292,17 @@
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否上线</span>
<span class="json-risk-meta-value">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineLabel === '是' }">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineValue }">
<span class="indicator-dot"></span>
{{ selectedSkill.isOnlineLabel || '' }}
{{ selectedSkill.isOnlineLabel || '待上线' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否启用</span>
<span class="json-risk-meta-label">规则状态</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.isEnabledTone">
{{ selectedSkill.isEnabledLabel || '-' }}
<span class="json-risk-meta-badge" :class="selectedSkill.statusTone">
{{ selectedSkill.status || '-' }}
</span>
</span>
</div>
@@ -329,6 +333,10 @@
<span class="json-risk-meta-label">上线时间</span>
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">最后操作</span>
<span class="json-risk-meta-value">{{ selectedSkill.lastOperationLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item full-width">
<span class="json-risk-meta-label">使用字段</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
@@ -623,17 +631,6 @@
</aside>
<aside v-else class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.triggerTitle }}</h3>
<p>{{ selectedSkill.triggerDesc }}</p>
</div>
</div>
<div class="tag-list">
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
@@ -702,64 +699,35 @@
<button
v-if="canToggleRiskRuleEnabled"
class="minor-action enable-action"
:class="{ 'is-on': selectedSkill.isEnabledValue }"
:class="{ 'is-on': selectedSkill.isOnlineValue }"
type="button"
:disabled="detailBusy"
@click="toggleSelectedRiskRuleEnabled"
>
<i :class="selectedSkill.isEnabledValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isEnabledValue ? '已启用' : '已停用' }}</span>
<i :class="selectedSkill.isOnlineValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isOnlineValue ? '已上线' : '已下线' }}</span>
</button>
<button
v-if="canOpenRiskRuleTest"
class="minor-action"
type="button"
:disabled="!canOpenRiskRuleTest"
:disabled="detailBusy"
@click="openRiskRuleTestDialog"
>
<i class="mdi mdi-flask-outline"></i>
<span>测试规则</span>
</button>
<button
v-if="selectedSkillUsesJsonRisk && canEditSelected"
v-if="canDeleteRiskRule"
class="minor-action danger-action"
type="button"
:disabled="!canDeleteRiskRule"
:disabled="detailBusy"
@click="openDeleteRiskRuleDialog"
:title="canDeleteRiskRule ? '删除未发布规则' : '已发布过的规则不能删除'"
title="删除未发布规则"
>
<i class="mdi mdi-delete-outline"></i>
<span>删除规则</span>
</button>
<button
v-if="canEditSelected && !riskRuleInReview"
class="major-action"
type="button"
:disabled="!canOpenRiskRuleReviewSubmit"
@click="openSubmitReviewDialog"
>
<i class="mdi mdi-send-outline"></i>
<span>提交审核</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="minor-action"
type="button"
:disabled="!canReturnRiskRule"
@click="openReturnRiskRuleDialog"
>
<i class="mdi mdi-keyboard-return"></i>
<span>回退规则</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="major-action"
type="button"
:disabled="!canPublishRiskRule"
@click="openPublishRiskRuleDialog"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>发布上线</span>
</button>
</template>
<button
v-else-if="selectedSkill.usesSpreadsheetRule"
@@ -1191,7 +1159,7 @@
badge="自然语言规则"
badge-tone="info"
title="新建风险规则"
description="默认创建报销类风险规则。选择费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
description="默认创建费用类风险规则。选择业务环节和费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
cancel-text="取消"
confirm-text="开始生成"
busy-text="生成中..."
@@ -1203,6 +1171,21 @@
@confirm="submitRiskRuleCreate"
>
<div class="risk-rule-create-form">
<label>
<span>业务环节</span>
<select
v-model="riskRuleCreateForm.business_stage"
:disabled="riskRuleCreateBusy"
>
<option
v-for="option in riskRuleBusinessStageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label>
<span>费用领域</span>
<select
@@ -1218,6 +1201,16 @@
</option>
</select>
</label>
<label>
<span>是否上传附件</span>
<select
v-model="riskRuleCreateForm.requires_attachment"
:disabled="riskRuleCreateBusy"
>
<option :value="true"></option>
<option :value="false"></option>
</select>
</label>
<label class="span-2">
<span>规则标题</span>
<input
@@ -1227,17 +1220,6 @@
placeholder="例如:差旅目的地与票据城市一致性校验"
/>
</label>
<label class="risk-rule-create-toggle span-2">
<input
v-model="riskRuleCreateForm.requires_attachment"
type="checkbox"
:disabled="riskRuleCreateBusy"
/>
<span>
<strong>测试时需要上传附件</strong>
<small>适用于依赖发票行程单合同等单据 OCR 字段的规则不勾选则测试窗口不显示附件上传</small>
</span>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea

View File

@@ -1,45 +1,73 @@
<template>
<section class="budget-center-page">
<header class="budget-local-head">
<h2>预算管理</h2>
</header>
<section class="budget-summary-grid" aria-label="预算概览">
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
<span class="summary-icon" :class="metric.tone">
<i :class="metric.icon"></i>
</span>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<em>{{ metric.note }}</em>
<article
v-for="(metric, index) in budgetMetrics"
:key="metric.label"
class="budget-summary-card"
:class="metric.tone"
:style="{ '--delay': `${index * 55}ms` }"
>
<div class="budget-summary-head">
<span class="summary-icon">
<i :class="metric.icon"></i>
</span>
<span class="summary-label">{{ metric.label }}</span>
</div>
<strong class="summary-value">{{ metric.value }}</strong>
<div class="summary-comparison-row">
<span class="comparison-pill" :class="metric.yoy.tone">
<b>同比</b>
<em>{{ metric.yoy.value }}</em>
<i :class="metric.yoy.icon"></i>
</span>
<span class="comparison-pill" :class="metric.mom.tone">
<b>环比</b>
<em>{{ metric.mom.value }}</em>
<i :class="metric.mom.icon"></i>
</span>
</div>
</article>
</section>
<section class="budget-filter-bar">
<label>
<span>预算周期</span>
<select v-model="filters.period">
<option v-for="period in periods" :key="period">{{ period }}</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.expenseType">
<option v-for="type in expenseTypes" :key="type">{{ type }}</option>
</select>
</label>
<label>
<span>状态</span>
<select v-model="filters.status">
<option v-for="status in statuses" :key="status">{{ status }}</option>
</select>
</label>
<button class="budget-primary-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建预算</span>
</button>
<div class="budget-filter-set">
<label>
<span>预算年度</span>
<select v-model="filters.year">
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
</select>
</label>
<label>
<span>预算季度</span>
<select v-model="filters.quarter">
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.expenseType">
<option v-for="type in expenseTypes" :key="type">{{ type }}</option>
</select>
</label>
<label>
<span>状态</span>
<select v-model="filters.status">
<option v-for="status in statuses" :key="status">{{ status }}</option>
</select>
</label>
</div>
<div class="budget-action-set">
<button class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑预算</span>
</button>
<button class="budget-ghost-btn" type="button">
<i class="mdi mdi-text-box-outline"></i>
<span>预算详情</span>
</button>
</div>
</section>
<section class="budget-work-grid">
@@ -81,7 +109,6 @@
<th>使用率</th>
<th>预警线</th>
<th>控制动作</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -99,24 +126,49 @@
</td>
<td :class="row.warningTone">{{ row.warningLine }}</td>
<td>{{ row.action }}</td>
<td>
<div class="budget-row-actions">
<button type="button">详情</button>
<button type="button">编辑</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="budget-table-foot">
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
<button type="button" class="active">1</button>
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
<select aria-label="每页条数">
<option>10 /</option>
</select>
<span> {{ visibleBudgetRows.length }} </span>
<div class="budget-pager" aria-label="预算分页">
<button
class="page-nav"
type="button"
:disabled="budgetPage <= 1"
@click="changeBudgetPage(-1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in budgetPageNumbers"
:key="page"
type="button"
:class="{ active: page === budgetPage }"
@click="goToBudgetPage(page)"
>
{{ page }}
</button>
<button
class="page-nav"
type="button"
:disabled="budgetPage >= totalBudgetPages"
@click="changeBudgetPage(1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<label class="budget-page-size">
<select v-model.number="budgetPageSize" aria-label="每页条数">
<option v-for="size in budgetPageSizeOptions" :key="size" :value="size">
{{ size }} /
</option>
</select>
<i class="mdi mdi-chevron-down"></i>
</label>
<span class="budget-page-summary">
{{ totalBudgetRows }} 当前第 {{ budgetPage }} / {{ totalBudgetPages }}
</span>
</footer>
</article>
</section>
@@ -152,9 +204,149 @@
</div>
</article>
</section>
<Teleport to="body">
<Transition name="budget-dialog-fade">
<div v-if="budgetEditOpen" class="budget-dialog-backdrop" @click.self="closeBudgetEditDialog">
<section class="budget-edit-dialog" role="dialog" aria-modal="true" aria-label="编辑预算">
<header class="budget-edit-head">
<strong>编辑预算</strong>
<button class="budget-dialog-close" type="button" aria-label="关闭" @click="closeBudgetEditDialog">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="budget-edit-body">
<section class="budget-edit-section">
<h3>基本信息</h3>
<div class="budget-edit-form-grid">
<label class="required">
<span>预算年度</span>
<select v-model="budgetEditForm.budgetYear">
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
</select>
</label>
<label class="required">
<span>预算季度</span>
<select v-model="budgetEditForm.budgetQuarter">
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
</select>
</label>
<label class="required">
<span>所属部门</span>
<select v-model="budgetEditForm.departmentCode">
<option v-for="department in departments" :key="department.code" :value="department.code">
{{ department.name }}
</option>
</select>
</label>
<label class="required">
<span>预算负责人</span>
<select v-model="budgetEditForm.budgetOwner">
<option>张晓明</option>
<option>李娜</option>
<option>王凯</option>
</select>
</label>
<label class="required">
<span>预算版本</span>
<select v-model="budgetEditForm.budgetVersion">
<option>V1.0初始版本</option>
<option>V1.1调整版本</option>
<option>V2.0发布版本</option>
</select>
</label>
<label class="required">
<span>预算状态</span>
<select v-model="budgetEditForm.budgetStatus">
<option v-for="status in statusOptions" :key="status">{{ status }}</option>
</select>
</label>
</div>
<label class="budget-edit-textarea">
<span>预算说明</span>
<textarea v-model="budgetEditForm.budgetDescription" maxlength="300"></textarea>
<em>{{ budgetEditForm.budgetDescription.length }}/300</em>
</label>
</section>
<section class="budget-edit-section">
<h3>预算明细</h3>
<div class="budget-edit-table-wrap">
<table class="budget-edit-table">
<thead>
<tr>
<th>费用类型 <i>*</i></th>
<th>预算金额 <i>*</i></th>
<th>预警线% <i>*</i></th>
<th>控制动作 <i>*</i></th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in budgetEditRows" :key="row.id">
<td>
<select v-model="row.budgetSubjectCode" @change="syncBudgetRowSubject(row)">
<option
v-for="option in expenseTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</td>
<td><input v-model="row.budgetAmount" type="text" inputmode="decimal" /></td>
<td>
<select v-model="row.warningThreshold">
<option v-for="warning in warningOptions" :key="warning">{{ warning }}</option>
</select>
</td>
<td>
<select v-model="row.controlAction">
<option v-for="action in controlActionOptions" :key="action">{{ action }}</option>
</select>
</td>
<td><input v-model="row.budgetRemark" type="text" /></td>
<td>
<button
class="budget-row-delete"
type="button"
aria-label="删除预算明细"
@click="removeBudgetDetailRow(row.id)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button class="budget-add-row-btn" type="button" @click="addBudgetDetailRow">
<i class="mdi mdi-plus"></i>
<span>添加行</span>
</button>
<div class="budget-edit-total">
<span>合计</span>
<strong>{{ budgetEditTotal }}</strong>
</div>
</section>
</div>
<footer class="budget-edit-foot">
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
<button class="budget-edit-draft" type="button" @click="saveBudgetDraft">保存草稿</button>
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
<style scoped src="../assets/styles/views/budget-center-dialog.css"></style>

View File

@@ -31,7 +31,7 @@ import {
updateAgentAsset
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js'
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
import {
buildReviewNote,
@@ -69,6 +69,7 @@ import {
} from './auditViewModel.js'
import {
createDefaultRiskRuleForm,
RISK_RULE_BUSINESS_STAGE_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
} from './auditViewRiskRuleModel.js'
@@ -144,11 +145,11 @@ export default {
financialRules: [],
riskRules: [],
skills: [],
mcp: [],
tasks: []
mcp: []
})
const isAdmin = computed(() => isManagerUser(currentUser.value))
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
const isRuleManager = computed(() => isManagerUser(currentUser.value))
const isFinance = computed(() => isFinanceUser(currentUser.value))
const activeMeta = computed(() => TAB_META[activeType.value])
const activeTabLabel = computed(() => activeMeta.value.label)
@@ -162,7 +163,7 @@ export default {
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => false)
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const showEnabledColumn = computed(() => false)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
@@ -171,6 +172,9 @@ export default {
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed(
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canAdminOperateSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canEditSelected = computed(
@@ -180,7 +184,7 @@ export default {
(isAdmin.value || isFinance.value)
)
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
() => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
@@ -196,27 +200,20 @@ export default {
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
!detailBusy.value
!riskRuleGenerationFailed.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
() => false
)
const canSubmitRiskRuleReview = computed(
() =>
@@ -224,17 +221,14 @@ export default {
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value
() => false
)
const canPublishRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canManageSelected.value &&
riskRuleInReview.value &&
riskRuleTestPassed.value
false
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
)
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
@@ -242,7 +236,11 @@ export default {
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
)
const canSubmitReview = computed(
() => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
() =>
!selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
selectedSkillIsRule.value &&
isDisplayingWorkingVersion.value
)
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed(
@@ -370,7 +368,7 @@ export default {
)
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const showEnabledFilter = computed(() => false)
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
@@ -646,6 +644,7 @@ export default {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
@@ -1007,8 +1006,13 @@ export default {
}
async function loadAssets(options = {}) {
loading.value = true
errorMessage.value = ''
const shouldShowLoading = !options.silent && !options.background
if (shouldShowLoading) {
loading.value = true
}
if (!options.silent) {
errorMessage.value = ''
}
try {
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
@@ -1037,6 +1041,9 @@ export default {
}
}
} catch (error) {
if (options.silent || options.background) {
return
}
if (activeMeta.value.assetType === 'rule') {
assetBuckets.value = {
...assetBuckets.value,
@@ -1056,12 +1063,14 @@ export default {
toast(errorMessage.value)
}
} finally {
loading.value = false
if (shouldShowLoading) {
loading.value = false
}
}
}
async function refreshCurrentAssets() {
await loadAssets({ force: true, silent: true })
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
@@ -1110,6 +1119,39 @@ export default {
}
}
function mergeSelectedRuleLifecycle(detail) {
if (!selectedSkill.value || !detail) {
return
}
const next = buildDetailViewModel(detail, runs.value)
selectedSkill.value = {
...selectedSkill.value,
status: next.status,
statusValue: next.statusValue,
statusTone: next.statusTone,
publishedVersion: next.publishedVersion,
workingVersion: next.workingVersion,
currentVersion: next.currentVersion,
displayVersion: next.displayVersion,
reviewer: next.reviewer,
publisher: next.publisher,
publishedAt: next.publishedAt,
isOnlineValue: next.isOnlineValue,
isOnlineLabel: next.isOnlineLabel,
isOnlineTone: next.isOnlineTone,
isEnabledValue: next.isEnabledValue,
isEnabledLabel: next.isEnabledLabel,
isEnabledTone: next.isEnabledTone,
latestTestSummary: next.latestTestSummary,
lastOperationLabel: next.lastOperationLabel,
lastOperationTone: next.lastOperationTone,
publishMeta: next.publishMeta,
publishState: next.publishState,
updatedAt: next.updatedAt,
configJson: next.configJson
}
}
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
@@ -1525,6 +1567,9 @@ export default {
}
function openRiskRuleTestDialog() {
if (detailBusy.value) {
return
}
if (!canOpenRiskRuleTest.value) {
if (!selectedSkill.value?.id) {
toast('规则详情还没有加载完成,请稍后再测试。')
@@ -1544,7 +1589,8 @@ export default {
}
await refreshCurrentAssets()
if (selectedSkill.value?.id) {
await loadSelectedAssetDetail(selectedSkill.value.id)
const detail = await fetchAgentAssetDetail(selectedSkill.value.id)
mergeSelectedRuleLifecycle(detail)
}
}
@@ -1659,15 +1705,15 @@ export default {
return
}
const assetId = selectedSkill.value.id
const nextEnabled = !selectedSkill.value.isEnabledValue
const nextEnabled = !selectedSkill.value.isOnlineValue
actionState.value = 'toggle-risk-rule-enabled'
try {
await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
await loadSelectedAssetDetail(assetId)
toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。')
toast(nextEnabled ? '风险规则已上线。' : '风险规则已下线,不会进入业务扫描。')
} catch (error) {
toast(error?.message || '风险规则启用状态更新失败,请稍后重试。')
toast(error?.message || '风险规则上线状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
@@ -1851,6 +1897,7 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
showReviewNote,
spreadsheetUploadInput,

View File

@@ -1,7 +1,18 @@
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
BUDGET_CONTROL_ACTION_OPTIONS,
BUDGET_EXPENSE_TYPE_OPTIONS,
BUDGET_QUARTER_OPTIONS,
BUDGET_STATUS_OPTIONS,
BUDGET_WARNING_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext,
formatBudgetPeriod,
resolveBudgetExpenseTypeLabel
} from '../../utils/budgetOntology.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
@@ -12,13 +23,34 @@ const FALLBACK_DEPARTMENTS = [
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BLUEPRINTS = [
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
]
const EXPENSE_BUDGET_SEED = {
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
hotel: { total: 360000, used: 139800, occupied: 84000, warning: 80, action: '提醒' },
transport: { total: 280000, used: 104600, occupied: 56000, warning: 75, action: '提醒' },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
meeting: { total: 260000, used: 84500, occupied: 52000, warning: 75, action: '提醒' },
marketing: { total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
office: { total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
training: { total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
software: { total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
welfare: { total: 240000, used: 96500, occupied: 42000, warning: 75, action: '提醒' }
}
const DEFAULT_EXPENSE_BUDGET = {
total: 100000,
used: 0,
occupied: 0,
warning: 70,
action: '正常'
}
const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({
...DEFAULT_EXPENSE_BUDGET,
...EXPENSE_BUDGET_SEED[option.value],
budgetSubjectCode: option.value,
expenseType: option.label
}))
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
@@ -26,14 +58,29 @@ const currency = (value) =>
maximumFractionDigits: 2
})
const comparison = (value, direction) => ({
value,
tone: direction === 'down' ? 'down' : 'up',
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
const seed = Array.from(String(departmentCode || '')).reduce(
(sum, char) => sum + char.charCodeAt(0),
0
)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
const occupiedAmount = Math.round(
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
)
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
@@ -80,18 +127,36 @@ export default {
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const filters = ref({
period: '2026年度',
year: '2026',
quarter: 'Q1',
expenseType: '全部',
status: '全部'
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetEditOpen = ref(false)
const budgetEditForm = ref({
budgetYear: '2026',
budgetQuarter: 'Q1',
budgetPeriod: '2026年Q1',
departmentCode: FALLBACK_DEPARTMENTS[0].code,
costCenter: FALLBACK_DEPARTMENTS[0].costCenter,
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: ''
})
const budgetEditRows = ref([])
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
const visibleBudgetRows = computed(() =>
const departmentRows = computed(() =>
buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value)
)
const filteredBudgetRows = computed(() =>
departmentRows.value
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
@@ -101,6 +166,21 @@ export default {
return row.rateTone === 'ok'
})
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 5)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const totals = computed(() => {
const rows = departmentRows.value
@@ -119,30 +199,34 @@ export default {
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
note: '本年累计',
yoy: comparison('+8.42%', 'up'),
mom: comparison('+2.16%', 'up'),
tone: 'green',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+12.68%', 'up'),
mom: comparison('+4.35%', 'up'),
tone: 'blue',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+6.37%', 'up'),
mom: comparison('-1.84%', 'down'),
tone: 'orange',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('-3.26%', 'down'),
mom: comparison('-2.08%', 'down'),
tone: 'green',
icon: 'mdi mdi-currency-cny'
icon: 'mdi mdi-cash'
}
])
@@ -170,6 +254,103 @@ export default {
)
const trendData = computed(() => buildTrendData(departmentRows.value))
const budgetEditTotal = computed(() =>
currency(
budgetEditRows.value.reduce(
(sum, row) => sum + parseBudgetAmount(row.budgetAmount),
0
)
)
)
const budgetOntologyContext = computed(() =>
buildBudgetOntologyContext({
form: budgetEditForm.value,
rows: budgetEditRows.value,
departments: departments.value
})
)
function buildEditableRows() {
return departmentRows.value.map((row) => ({
id: makeBudgetRowId(),
budgetSubject: row.expenseType,
budgetSubjectCode: row.budgetSubjectCode || '',
budgetAmount: currency(row.totalAmount),
warningThreshold: `${row.warning}%`,
controlAction: row.action,
budgetRemark: `${row.expenseType}相关费用`
}))
}
function resolveNextExpenseTypeOption() {
const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode))
return (
BUDGET_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
BUDGET_EXPENSE_TYPE_OPTIONS[0]
)
}
function syncBudgetRowSubject(row) {
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
}
function openBudgetEditDialog() {
const department = activeDepartment.value
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
budgetEditForm.value = {
budgetYear: filters.value.year,
budgetQuarter: filters.value.quarter,
budgetPeriod,
departmentCode: department?.code || activeDepartmentCode.value,
costCenter: department?.costCenter || '',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: `${department?.name || '当前部门'}2026年度预算编制用于指导费用支出及控制成本确保资源合理使用。`
}
budgetEditRows.value = buildEditableRows()
budgetEditOpen.value = true
}
function closeBudgetEditDialog() {
budgetEditOpen.value = false
}
function addBudgetDetailRow() {
const option = resolveNextExpenseTypeOption()
budgetEditRows.value.push({
id: makeBudgetRowId(),
budgetSubject: option.label,
budgetSubjectCode: option.value,
budgetAmount: '0.00',
warningThreshold: '70%',
controlAction: '正常',
budgetRemark: ''
})
}
function removeBudgetDetailRow(rowId) {
if (budgetEditRows.value.length <= 1) return
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPage(direction) {
goToBudgetPage(currentBudgetPage.value + direction)
}
function saveBudgetDraft() {
budgetEditForm.value.budgetStatus = '编制中'
closeBudgetEditDialog()
}
function publishBudget() {
budgetEditForm.value.budgetStatus = '已发布'
closeBudgetEditDialog()
}
async function loadDepartments() {
try {
@@ -198,19 +379,65 @@ export default {
void loadDepartments()
})
watch(
[
activeDepartmentCode,
budgetPageSize,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status
],
() => {
budgetPage.value = 1
}
)
watch(totalBudgetPages, (pages) => {
if (budgetPage.value > pages) {
budgetPage.value = pages
}
})
return {
activeDepartmentCode,
activeDepartmentName,
addBudgetDetailRow,
budgetEditForm,
budgetEditOpen,
budgetEditRows,
budgetEditTotal,
budgetMetrics,
budgetOntologyContext,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
closeBudgetEditDialog,
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
changeBudgetPage,
departmentKeyword,
departments,
expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
periods: ['2026年度', '2026年Q2', '2026年5月'],
openBudgetEditDialog,
quarters: BUDGET_QUARTER_OPTIONS,
publishBudget,
removeBudgetDetailRow,
saveBudgetDraft,
statusOptions: BUDGET_STATUS_OPTIONS,
statuses: ['全部', '正常', '预警', '管控'],
syncBudgetRowSubject,
goToBudgetPage,
totalBudgetPages,
totalBudgetRows,
trendData,
visibleBudgetRows,
visibleDepartments,
warnings
warningOptions: BUDGET_WARNING_OPTIONS,
warnings,
years: BUDGET_YEAR_OPTIONS
}
}
}

View File

@@ -56,23 +56,6 @@ export const TYPE_META = {
version: '当前版本',
metric: '超时配置'
}
},
tasks: {
assetType: 'task',
label: '任务',
typeLabel: '任务',
createButtonLabel: '任务已接入',
hintText: '任务页签已接到真实资产 API可查看调度周期、执行 Agent 和最近执行结果。',
searchPlaceholder: '搜索任务名称、编码或负责人',
tableColumns: {
name: '任务名称',
category: '业务域',
owner: '负责人',
scope: '适用场景',
runtime: '调度周期',
version: '当前版本',
metric: '执行 Agent'
}
}
}
@@ -113,20 +96,15 @@ export const TAB_META = {
...TYPE_META.mcp,
typeKey: 'mcp',
badgeTone: 'amber'
},
tasks: {
...TYPE_META.tasks,
typeKey: 'tasks',
badgeTone: 'violet'
}
}
export const STATUS_META = {
generating: { label: '生成中', tone: 'info' },
draft: { label: '草稿中', tone: 'draft' },
draft: { label: '待上线', tone: 'draft' },
review: { label: '待审核', tone: 'warning' },
active: { label: '已上线', tone: 'success' },
disabled: { label: '已停用', tone: 'disabled' },
disabled: { label: '已下线', tone: 'disabled' },
failed: { label: '生成失败', tone: 'danger' }
}
@@ -230,34 +208,16 @@ export const DETAIL_TITLES = {
historyDesc: '最近版本记录',
publishTitle: '服务状态',
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
},
tasks: {
configTitle: '任务配置',
configDesc: '展示调度周期、执行 Agent 和任务编码。',
detailTitle: '任务结构',
detailDesc: '按调度计划、目标场景和运行结果组织任务信息。',
outputTitle: '运行要求',
outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。',
ruleListTitle: '运行要求',
checkListTitle: '最近执行',
triggerTitle: '适用场景',
triggerDesc: '当前任务覆盖的业务场景',
toolTitle: '最近调用',
toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况',
historyTitle: '版本历史',
historyDesc: '最近版本记录',
publishTitle: '调度状态',
publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。'
}
}
export const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'generating', label: '生成中' },
{ value: 'draft', label: '草稿中' },
{ value: 'draft', label: '待上线' },
{ value: 'review', label: '待审核' },
{ value: 'active', label: '已上线' },
{ value: 'disabled', label: '已停用' },
{ value: 'disabled', label: '已下线' },
{ value: 'failed', label: '生成失败' }
]

View File

@@ -207,6 +207,65 @@ export function resolveRiskRuleEnabled(source, rulePayload = null) {
return true
}
const LAST_OPERATION_LABELS = {
generate: '开始生成',
create: '创建',
test: '测试',
online: '上线',
offline: '下线',
delete: '删除',
update: '更新'
}
const RISK_RULE_BUSINESS_STAGE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
}
function resolveRiskRuleBusinessStage(source, rulePayload = null) {
const configJson = readConfigJson(source)
const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {}
const stage =
normalizeText(configJson.business_stage) ||
normalizeText(metadata.business_stage) ||
normalizeText(rulePayload?.business_stage)
const label =
normalizeText(configJson.business_stage_label) ||
normalizeText(metadata.business_stage_label) ||
RISK_RULE_BUSINESS_STAGE_LABELS[stage]
return {
value: stage || 'reimbursement',
label: label || '费用报销'
}
}
function resolveRiskRuleOnlineMeta(statusValue) {
if (statusValue === 'active') {
return { label: '已上线', tone: 'success', online: true }
}
if (statusValue === 'disabled') {
return { label: '已下线', tone: 'disabled', online: false }
}
if (statusValue === 'generating') {
return { label: '生成中', tone: 'info', online: false }
}
if (statusValue === 'failed') {
return { label: '生成失败', tone: 'danger', online: false }
}
return { label: '待上线', tone: 'draft', online: false }
}
function resolveLastOperationLabel(source, fallback = {}) {
const configJson = readConfigJson(source)
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统'
const at = normalizeText(operation.at) || normalizeText(fallback.at)
const actionLabel = LAST_OPERATION_LABELS[action] || action
const timeLabel = formatDateTime(at)
return timeLabel && timeLabel !== '-' ? `${actionLabel}${actor} · ${timeLabel}` : `${actionLabel}${actor}`
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
@@ -458,12 +517,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const businessStage = resolveRiskRuleBusinessStage(target, rulePayload)
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const onlineMeta = resolveRiskRuleOnlineMeta(statusValue)
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher =
@@ -488,6 +548,8 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
@@ -521,10 +583,16 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineLabel,
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
lastOperationLabel: resolveLastOperationLabel(target, {
actor: publisher,
at: riskRuleCreatedAt
}),
publisher,
publishedAt
}
@@ -747,7 +815,7 @@ export function resolveTypeKey(assetType) {
if (assetType === 'mcp') {
return 'mcp'
}
return 'tasks'
return ''
}
export function formatSeverity(value) {
@@ -778,23 +846,6 @@ export function formatOutputSummary(items) {
return `${items.length} 项输出`
}
export function formatTaskRisk(scenarios) {
if (Array.isArray(scenarios) && scenarios.includes('risk_check')) {
return '高风险'
}
if (
Array.isArray(scenarios) &&
(scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable'))
) {
return '中风险'
}
return '常规'
}
export function findLatestTaskRun(runs, assetId) {
return runs.find((item) => item.task_id === assetId) || null
}
export function findLatestMcpCall(runs, assetCode) {
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
@@ -827,7 +878,7 @@ export function buildRowRuntime(asset, typeKey) {
if (typeKey === 'mcp') {
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
}
return normalizeText(asset.config_json?.cron) || '未配置调度'
return ''
}
export function buildRowMetric(asset, typeKey) {
@@ -840,7 +891,7 @@ export function buildRowMetric(asset, typeKey) {
if (typeKey === 'mcp') {
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
}
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
return ''
}
export function formatSpreadsheetChangeSummary(summary) {
@@ -885,7 +936,8 @@ export function buildListItem(asset) {
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const isOnlineValue = asset.status === 'active'
const onlineMeta = resolveRiskRuleOnlineMeta(asset.status)
const isOnlineValue = onlineMeta.online
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const creator =
@@ -895,6 +947,9 @@ export function buildListItem(asset) {
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(asset)
: { value: '', label: '' }
return {
id: asset.id,
@@ -915,6 +970,8 @@ export function buildListItem(asset) {
reviewer,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory: ruleScenarioCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
model: buildRowRuntime(asset, typeKey),
version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
@@ -928,8 +985,8 @@ export function buildListItem(asset) {
publisher,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
@@ -1002,21 +1059,7 @@ export function buildMcpFields(detail, latestCall) {
]
}
export function buildTaskFields(detail, latestRun) {
const content = detail.current_version_content || {}
return [
{ label: '任务编码', value: detail.code },
{ label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' },
{ label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' },
{ label: '风险等级', value: formatTaskRisk(detail.scenario_json) },
{
label: '最近执行',
value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录'
}
]
}
export function buildFields(detail, typeKey, latestRun, latestCall) {
export function buildFields(detail, typeKey, latestCall) {
if (typeKey === 'rules') {
return buildRuleFields(detail)
}
@@ -1026,10 +1069,10 @@ export function buildFields(detail, typeKey, latestRun, latestCall) {
if (typeKey === 'mcp') {
return buildMcpFields(detail, latestCall)
}
return buildTaskFields(detail, latestRun)
return []
}
export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
export function buildPromptSections(detail, typeKey) {
const content = detail.current_version_content || {}
if (typeKey === 'skills') {
@@ -1075,26 +1118,10 @@ export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
title: '任务场景',
intent: '调度目标',
content: formatScenarioList(detail.scenario_json)
},
{
title: '执行 Agent',
intent: '运行主体',
content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。'
},
{
title: '最近执行结果',
intent: '运行反馈',
content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。'
}
]
return []
}
export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
export function buildOutputRules(detail, typeKey) {
const content = detail.current_version_content || {}
if (typeKey === 'rules') {
@@ -1130,15 +1157,10 @@ export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
]
}
return [
`调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`,
`执行 Agent${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`,
`风险等级:${formatTaskRisk(detail.scenario_json)}`,
`最近执行结果:${latestRun?.status || '暂无执行记录'}`
]
return []
}
export function buildTests(detail, typeKey, latestRun, latestCall) {
export function buildTests(detail, typeKey, latestCall) {
if (typeKey === 'rules') {
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
return [
@@ -1195,23 +1217,10 @@ export function buildTests(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
name: '最近运行状态',
input: latestRun?.run_id || '暂无运行',
result: latestRun?.status || '未记录',
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success'
},
{
name: '结果摘要',
input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置',
result: latestRun?.result_summary || '暂无摘要',
tone: 'success'
}
]
return []
}
export function buildTools(detail, typeKey, latestRun, latestCall) {
export function buildTools(detail, typeKey, latestCall) {
const content = detail.current_version_content || {}
if (typeKey === 'skills') {
@@ -1246,26 +1255,7 @@ export function buildTools(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent',
scope: '执行 Agent',
mode: '调度',
tone: 'active'
},
{
name: latestRun?.run_id || '暂无执行记录',
scope: '最近 Run',
mode: latestRun?.status || '未执行',
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active'
},
{
name: latestRun?.permission_level || '未记录',
scope: '权限级别',
mode: 'Trace',
tone: 'safe'
}
]
return []
}
export function buildPublishDescription(detail, typeKey) {
@@ -1279,14 +1269,16 @@ export function buildPublishDescription(detail, typeKey) {
return '当前规则需要先完成审核,再调用上线接口正式激活。'
}
return DETAIL_TITLES[typeKey].publishDesc
return DETAIL_TITLES[typeKey]?.publishDesc || ''
}
export function buildDetailViewModel(detail, runs) {
const typeKey = resolveTypeKey(detail.asset_type)
const tabId = resolveTabId(detail, typeKey) || typeKey
if (!typeKey || !tabId) {
return null
}
const tabMeta = resolveTabMeta(tabId, typeKey)
const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null
const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null
const configJson = readConfigJson(detail)
const statusMeta = resolveStatusMeta(detail.status)
@@ -1320,6 +1312,14 @@ export function buildDetailViewModel(detail, runs) {
normalizeText(detail.owner) ||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
'未知'
const onlineMeta = resolveRiskRuleOnlineMeta(detail.status)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(detail)
: { value: '', label: '' }
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
return {
id: detail.id,
@@ -1335,6 +1335,8 @@ export function buildDetailViewModel(detail, runs) {
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
category: resolveDomainLabel(detail.domain),
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
version: detail.working_version || detail.current_version || '-',
currentVersion: detail.current_version || '-',
publishedVersion: detail.published_version || '-',
@@ -1356,15 +1358,19 @@ export function buildDetailViewModel(detail, runs) {
riskRuleBusinessDescription: '',
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
riskRuleSourceRef: '',
riskRuleSeverity: 'medium',
riskRuleSeverityLabel: '中风险',
riskRuleScore: null,
riskRuleScoreLabel: '待计算',
riskRuleScoreLevel: 'medium',
riskRuleScoreDetail: null,
riskRuleSeverity: initialRiskRuleSeverity,
riskRuleScore: initialRiskRuleScore,
riskRuleScoreLevel: initialRiskRuleScoreLevel || initialRiskRuleSeverity,
riskRuleScoreDetail: resolveRiskRuleScoreDetail(configJson, configJson),
riskRuleSeverityLabel: initialRiskRuleScoreLevel
? resolveRiskRuleScoreLabel(configJson, configJson)
: resolveRiskRuleSeverityLabel(configJson),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(configJson, configJson),
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
@@ -1381,6 +1387,11 @@ export function buildDetailViewModel(detail, runs) {
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
lastOperationLabel: resolveLastOperationLabel(detail, {
actor: riskRuleCreator,
at: detail.created_at
}),
lastOperationTone: onlineMeta.tone,
riskRuleFields: [],
riskRuleFieldSummary: '未识别字段',
riskRuleFlow: resolveRiskRuleFlow({}, []),
@@ -1411,13 +1422,12 @@ export function buildDetailViewModel(detail, runs) {
reviewStatusValue: detail.latest_review?.review_status || '',
reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at),
reviewNote: detail.latest_review?.review_note || '',
latestRun,
latestCall,
fields: buildFields(detail, typeKey, latestRun, latestCall),
fields: buildFields(detail, typeKey, latestCall),
promptSections:
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
tests: buildTests(detail, typeKey, latestRun, latestCall),
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey),
outputRules: buildOutputRules(detail, typeKey),
tests: buildTests(detail, typeKey, latestCall),
triggers:
typeKey === 'rules'
? [ruleScenarioCategory || '通用']
@@ -1446,7 +1456,7 @@ export function buildDetailViewModel(detail, runs) {
tone: 'safe'
}
]
: buildTools(detail, typeKey, latestRun, latestCall),
: buildTools(detail, typeKey, latestCall),
history,
configTitle: titles.configTitle,
configDesc: titles.configDesc,
@@ -1467,9 +1477,7 @@ export function buildDetailViewModel(detail, runs) {
publishMeta:
typeKey === 'rules'
? `最近保存:${formatDateTime(detail.updated_at)}`
: latestRun
? `最近运行:${formatDateTime(latestRun.started_at)}`
: `最近更新:${formatDateTime(detail.updated_at)}`,
: `最近更新:${formatDateTime(detail.updated_at)}`,
publishState: statusMeta.label,
latestReviewVersion: detail.latest_review?.version || detail.current_version || '-',
loading: false

View File

@@ -16,6 +16,11 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'welfare', label: '福利费' }
]
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
{ value: 'expense_application', label: '费用申请' },
{ value: 'reimbursement', label: '费用报销' }
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'low', label: '低风险' },
{ value: 'medium', label: '中风险' },
@@ -49,6 +54,7 @@ const CITY_ROUTE_SEMANTIC_TYPES = new Set([
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
business_stage: 'reimbursement',
expense_category: 'travel',
rule_title: '',
requires_attachment: false,

View File

@@ -25,8 +25,10 @@ export const EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
@@ -96,8 +98,10 @@ export const REVIEW_FALLBACK_GROUP_CODES = [
'hotel',
'meal',
'meeting',
'marketing',
'office',
'training',
'software',
'communication',
'welfare'
]
@@ -113,7 +117,9 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
{ key: 'meeting', label: '会务费' },
{ key: 'marketing', label: '市场推广费' },
{ key: 'training', label: '培训费' },
{ key: 'software', label: '软件服务费' },
{ key: 'communication', label: '通讯费' },
{ key: 'welfare', label: '福利费' },
{ key: 'other', label: '其他费用' }
@@ -140,9 +146,11 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = {
transport: [TRANSPORT_KEYWORD_PATTERN],
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
marketing: [/市场推广|推广费|广告|投放|品牌宣传|营销物料|推广物料/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
training: [/培训|授课|讲师|课程|签到|讲义/],
software: [/软件|SaaS|订阅|系统服务|云服务|云资源|平台服务|技术服务/],
communication: [/通讯|电话|流量|话费|宽带|网络/],
welfare: [/福利|体检|团建|节日|慰问|关怀/]
}

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
BUDGET_EXPENSE_TYPE_OPTIONS,
BUDGET_ONTOLOGY_FIELDS,
BUDGET_QUARTER_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext
} from '../src/utils/budgetOntology.js'
test('budget ontology fields expose required budget center keys', () => {
const requiredKeys = BUDGET_ONTOLOGY_FIELDS
.filter((field) => field.required)
.map((field) => field.key)
assert.deepEqual(requiredKeys, [
'budget_period',
'department',
'cost_center',
'budget_owner',
'budget_version',
'budget_status',
'budget_subject',
'budget_amount',
'warning_threshold',
'control_action'
])
})
test('budget ontology context maps dialog fields to ontology payload', () => {
const context = buildBudgetOntologyContext({
form: {
budgetYear: '2026',
budgetQuarter: 'Q2',
departmentCode: 'MARKET-DEPT',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: '市场部预算编制'
},
departments: [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' }
],
rows: [
{
budgetSubject: '差旅费',
budgetSubjectCode: 'travel',
budgetAmount: '600,000.00',
warningThreshold: '80%',
controlAction: '提醒',
budgetRemark: '差旅相关费用'
}
]
})
assert.equal(context.document_type, 'budget_plan')
assert.equal(context.conversation_scenario, 'budget')
assert.equal(context.budget_header.budget_period, '2026年Q2')
assert.equal(context.budget_header.budget_year, '2026')
assert.equal(context.budget_header.budget_quarter, 'Q2')
assert.equal(context.budget_header.department, '市场部')
assert.equal(context.budget_header.cost_center, 'CC-4100')
assert.equal(context.budget_details[0].budget_subject_code, 'travel')
assert.equal(context.budget_details[0].expense_type, 'travel')
assert.equal(context.budget_details[0].expense_type_label, '差旅费')
assert.equal(context.budget_details[0].warning_threshold, '80%')
})
test('budget expense type options expose real expense type codes', () => {
const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value)
assert.deepEqual(optionCodes, [
'travel',
'hotel',
'transport',
'meal',
'meeting',
'marketing',
'office',
'training',
'software',
'communication',
'welfare'
])
})
test('budget center exposes separate year and quarter dimensions', () => {
assert.deepEqual(BUDGET_YEAR_OPTIONS, ['2026', '2027', '2028'])
assert.deepEqual(BUDGET_QUARTER_OPTIONS, ['Q1', 'Q2', 'Q3', 'Q4'])
})