feat(web): 更新审批中心、审计、政策制度页面及对应的业务脚本,增强前端交互逻辑
This commit is contained in:
@@ -414,6 +414,11 @@
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
|
||||
</div>
|
||||
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
|
||||
<span>{{ filter }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
@@ -423,8 +428,35 @@
|
||||
|
||||
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载审批待办</strong>
|
||||
<p>直属领导和财务节点下可处理的报销单据会直接展示在这里。</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<strong>审批列表加载失败</strong>
|
||||
<p>{{ error }}</p>
|
||||
<button class="state-action" type="button" @click="reload">重新加载</button>
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="showEmpty"
|
||||
:eyebrow="approvalEmptyState.eyebrow"
|
||||
:title="approvalEmptyState.title"
|
||||
:description="approvalEmptyState.desc"
|
||||
:icon="approvalEmptyState.icon"
|
||||
:action-label="approvalEmptyState.actionLabel"
|
||||
:action-icon="approvalEmptyState.actionIcon"
|
||||
:tone="approvalEmptyState.tone"
|
||||
:art-label="approvalEmptyState.artLabel"
|
||||
:tips="approvalEmptyState.tips"
|
||||
@action="handleEmptyAction"
|
||||
/>
|
||||
|
||||
<table v-else>
|
||||
<colgroup>
|
||||
<col><col><col><col><col><col><col><col><col><col><col>
|
||||
</colgroup>
|
||||
@@ -469,22 +501,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="list-foot">
|
||||
<span class="page-summary">共 126 条,当前第 1 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
|
||||
<button class="page-number active" type="button" aria-current="page">1</button>
|
||||
<button class="page-number" type="button">2</button>
|
||||
<button class="page-number" type="button">3</button>
|
||||
<button class="page-number" type="button">4</button>
|
||||
<button class="page-number" type="button">5</button>
|
||||
<span>...</span>
|
||||
<button class="page-number" type="button">13</button>
|
||||
<button class="page-nav" type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
|
||||
</div>
|
||||
<button class="page-size" type="button">10 条/页 <i class="mdi mdi-chevron-down"></i></button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>Markdown 规则内容</h3>
|
||||
<p>当前展示版本:{{ selectedSkill.displayVersion }},保存后会生成新的版本快照。</p>
|
||||
<p>当前展示版本:{{ selectedSkill.displayVersion }},规则说明与运行时 JSON 分开编辑,但保存时会一起进入版本快照。</p>
|
||||
</div>
|
||||
<button
|
||||
class="mini-btn primary"
|
||||
@@ -118,7 +118,7 @@
|
||||
</label>
|
||||
|
||||
<div class="editor-foot">
|
||||
<span>版本说明:{{ selectedSkill.currentVersionChangeNote }}</span>
|
||||
<span>版本说明:{{ selectedSkill.displayVersionChangeNote }}</span>
|
||||
<span>最近保存:{{ selectedSkill.updatedAt }}</span>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,60 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="selectedSkill.type === 'rules'" class="detail-card panel json-editor-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>运行时 JSON</h3>
|
||||
<p>编辑规则中心实际消费的 `config_json.runtime_rule`,保存时会同步写入配置并追加到 Markdown 版本快照。</p>
|
||||
</div>
|
||||
<button
|
||||
class="mini-btn"
|
||||
type="button"
|
||||
:disabled="!canEditMarkdown || detailBusy"
|
||||
@click="saveRuleRuntimeJson"
|
||||
>
|
||||
<i class="mdi mdi-code-json"></i>
|
||||
<span>{{ actionState === 'save-runtime-json' ? '保存中...' : '保存 JSON' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="json-template-meta">
|
||||
<span>
|
||||
<strong>模板</strong>
|
||||
{{ selectedSkill.ruleTemplateLabel }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>模板键</strong>
|
||||
{{ selectedSkill.ruleTemplateKey || '未指定' }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>运行时类型</strong>
|
||||
{{ selectedSkill.runtimeKind || 'policy_rule_draft' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>config_json.runtime_rule</span>
|
||||
<textarea
|
||||
v-model="selectedSkill.runtimeRuleText"
|
||||
class="json-editor"
|
||||
:class="{ disabled: !canEditMarkdown }"
|
||||
spellcheck="false"
|
||||
:readonly="!canEditMarkdown || detailBusy"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="editor-foot">
|
||||
<span>JSON 必须是对象;保存后会同步写入资产配置,并以 `expense-rule` 代码块落到版本历史里。</span>
|
||||
<span>当前展示版本:{{ selectedSkill.displayVersion }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!canEditMarkdown" class="review-note-block muted">
|
||||
<strong>只读模式</strong>
|
||||
<p>当前账号没有规则编辑权限,运行时 JSON 仅可查看。</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
@@ -539,7 +593,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载{{ activeTabLabel }}资产...</p>
|
||||
@@ -608,7 +662,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="!loading && !errorMessage" class="list-foot">
|
||||
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
|
||||
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
|
||||
<div class="doc-table-wrap">
|
||||
<table>
|
||||
<table class="knowledge-document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名称</th>
|
||||
@@ -91,21 +91,51 @@
|
||||
<td>{{ doc.version }}</td>
|
||||
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
|
||||
<td>{{ doc.owner }}</td>
|
||||
<td>
|
||||
<div class="row-actions" @click.stop>
|
||||
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
|
||||
<i class="mdi mdi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="more-btn danger"
|
||||
type="button"
|
||||
:disabled="deletingId === doc.id"
|
||||
aria-label="删除文件"
|
||||
@click="handleDelete(doc)"
|
||||
>
|
||||
<i class="mdi mdi-delete-outline"></i>
|
||||
</button>
|
||||
<td>
|
||||
<div class="row-actions" @click.stop>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="more-btn ingest"
|
||||
type="button"
|
||||
:disabled="Boolean(ingestingId) || deletingId === doc.id"
|
||||
:aria-label="resolveIngestActionTitle(doc)"
|
||||
:title="resolveIngestActionTitle(doc)"
|
||||
@click="handleManualIngest(doc)"
|
||||
>
|
||||
<i class="mdi mdi-book-sync-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="more-btn llm-wiki-view"
|
||||
:class="{ 'is-disabled': !canViewLlmWiki(doc) }"
|
||||
type="button"
|
||||
:aria-label="resolveViewLlmWikiTitle(doc)"
|
||||
:aria-disabled="!canViewLlmWiki(doc)"
|
||||
:title="resolveViewLlmWikiTitle(doc)"
|
||||
@click="openLlmWikiSummary(doc)"
|
||||
>
|
||||
<i class="mdi mdi-eye-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
class="more-btn"
|
||||
type="button"
|
||||
aria-label="下载文件"
|
||||
title="下载当前文件"
|
||||
@click="handleDownload(doc)"
|
||||
>
|
||||
<i class="mdi mdi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="more-btn danger"
|
||||
type="button"
|
||||
:disabled="deletingId === doc.id || ingestingId === doc.id"
|
||||
aria-label="删除文件"
|
||||
title="删除当前文件"
|
||||
@click="handleDelete(doc)"
|
||||
>
|
||||
<i class="mdi mdi-delete-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -277,6 +307,115 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<Transition name="preview-modal">
|
||||
<div
|
||||
v-if="llmWikiDialogOpen"
|
||||
class="preview-modal-overlay llm-wiki-overlay"
|
||||
role="presentation"
|
||||
@click.self="closeLlmWikiSummary"
|
||||
>
|
||||
<aside class="preview-modal-shell llm-wiki-shell" role="dialog" aria-modal="true" aria-labelledby="llm-wiki-title">
|
||||
<article
|
||||
ref="llmWikiDialogPanel"
|
||||
class="preview-panel preview-modal-panel llm-wiki-panel panel"
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
>
|
||||
<header class="preview-head llm-wiki-head">
|
||||
<div class="preview-copy">
|
||||
<h2 id="llm-wiki-title">LLM Wiki 归纳内容</h2>
|
||||
<p>{{ llmWikiDocument ? llmWikiDocument.document_name : '正在读取当前文档的 LLM Wiki 知识总结。' }}</p>
|
||||
<div v-if="llmWikiDocument" class="llm-wiki-meta">
|
||||
<span>版本 {{ llmWikiDocument.document_version }}</span>
|
||||
<span>{{ llmWikiDocument.chunk_count }} 个分块</span>
|
||||
<span>{{ llmWikiDocument.knowledge_candidate_count }} 条知识</span>
|
||||
<span>{{ llmWikiDocument.rule_candidate_count }} 条规则草稿</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="mini-action"
|
||||
:disabled="llmWikiLoading || llmWikiSaving || !llmWikiDocument"
|
||||
@click="saveLlmWikiSummary"
|
||||
>
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ llmWikiSaving ? '保存中...' : '保存总结' }}</span>
|
||||
</button>
|
||||
<button type="button" class="icon-action" aria-label="关闭归纳内容" @click="closeLlmWikiSummary">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="llm-wiki-body">
|
||||
<div v-if="llmWikiLoading" class="preview-status">正在加载 LLM Wiki 归纳内容...</div>
|
||||
<div v-else-if="llmWikiError" class="preview-status error">{{ llmWikiError }}</div>
|
||||
<div v-else-if="llmWikiDocument" class="llm-wiki-grid">
|
||||
<section class="llm-wiki-section llm-wiki-summary-section">
|
||||
<div class="llm-wiki-section-head">
|
||||
<div>
|
||||
<h3>知识总结</h3>
|
||||
<p>管理员可在审核后修订这份归纳总结,作为知识预览内容保留。</p>
|
||||
</div>
|
||||
<span class="llm-wiki-count">{{ llmWikiDocument.knowledge_candidate_count }} 条知识</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="llmWikiSummaryDraft"
|
||||
class="llm-wiki-editor"
|
||||
spellcheck="false"
|
||||
placeholder="Hermes 归纳后的知识总结会显示在这里。"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<section class="llm-wiki-section llm-wiki-candidates-section">
|
||||
<div class="llm-wiki-section-head">
|
||||
<div>
|
||||
<h3>知识条目预览</h3>
|
||||
<p>展示 Hermes 已提炼出的知识点,方便与总结内容逐项比对。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="llmWikiDocument.knowledge_candidates.length" class="llm-wiki-candidate-list">
|
||||
<article
|
||||
v-for="candidate in llmWikiDocument.knowledge_candidates"
|
||||
:key="candidate.candidate_id"
|
||||
class="llm-wiki-candidate-card"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ candidate.title }}</strong>
|
||||
<span>{{ candidate.scenario }}</span>
|
||||
</header>
|
||||
<p>{{ candidate.content }}</p>
|
||||
<div v-if="candidate.tags.length" class="llm-wiki-chip-list">
|
||||
<span
|
||||
v-for="tag in candidate.tags"
|
||||
:key="`${candidate.candidate_id}-${tag}`"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<ul v-if="candidate.evidence.length" class="llm-wiki-evidence">
|
||||
<li
|
||||
v-for="(evidence, index) in candidate.evidence.slice(0, 3)"
|
||||
:key="`${candidate.candidate_id}-evidence-${index}`"
|
||||
>
|
||||
{{ evidence }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="preview-status">当前文档暂无可展示的知识条目。</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,59 +1,354 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
|
||||
const RISK_LABELS = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险'
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const amount = Number(value)
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(Number.isFinite(amount) ? amount : 0)
|
||||
}
|
||||
|
||||
function resolveRiskTone(riskFlags, riskSummary) {
|
||||
if (Array.isArray(riskFlags)) {
|
||||
const severities = riskFlags
|
||||
.map((item) => String(item?.severity || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
if (severities.includes('high')) {
|
||||
return 'high'
|
||||
}
|
||||
if (severities.includes('medium')) {
|
||||
return 'medium'
|
||||
}
|
||||
if (severities.includes('low')) {
|
||||
return 'low'
|
||||
}
|
||||
}
|
||||
|
||||
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function resolveRiskItems(request) {
|
||||
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
||||
const items = riskFlags
|
||||
.map((item) => {
|
||||
const tone = resolveRiskTone([item], '')
|
||||
const text = String(item?.message || item?.label || item?.reason || '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text,
|
||||
level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低',
|
||||
tone,
|
||||
icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (items.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const summary = String(request?.riskSummary || '').trim()
|
||||
if (summary && summary !== '无') {
|
||||
return summary.split(';').filter(Boolean).map((text) => ({
|
||||
text,
|
||||
level: '中',
|
||||
tone: 'medium',
|
||||
icon: 'mdi mdi-alert'
|
||||
}))
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: 'AI验审已通过,当前未发现额外风险。',
|
||||
level: '低',
|
||||
tone: 'low',
|
||||
icon: 'mdi mdi-shield-check'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveAttachmentMeta(name) {
|
||||
const normalized = String(name || '').trim()
|
||||
const lowerName = normalized.toLowerCase()
|
||||
if (lowerName.endsWith('.pdf')) {
|
||||
return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }
|
||||
}
|
||||
if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) {
|
||||
return { icon: 'mdi mdi-image', iconClass: 'img' }
|
||||
}
|
||||
return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' }
|
||||
}
|
||||
|
||||
function buildAttachments(expenseItems) {
|
||||
const seen = new Set()
|
||||
const attachments = []
|
||||
|
||||
for (const item of Array.isArray(expenseItems) ? expenseItems : []) {
|
||||
for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) {
|
||||
const normalized = String(fileName || '').trim()
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalized)
|
||||
attachments.push({
|
||||
name: normalized,
|
||||
size: '已识别',
|
||||
...resolveAttachmentMeta(normalized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length) {
|
||||
return attachments
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: '当前无附件',
|
||||
size: '待补充',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
iconClass: 'miss',
|
||||
missing: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveSlaMeta(submittedAt) {
|
||||
const startAt = toDate(submittedAt)
|
||||
if (!startAt) {
|
||||
return { label: '待处理', tone: 'safe', urgent: false }
|
||||
}
|
||||
|
||||
const deadline = new Date(startAt.getTime() + DEFAULT_SLA_HOURS * 60 * 60 * 1000)
|
||||
const diffMs = deadline.getTime() - Date.now()
|
||||
if (diffMs <= 0) {
|
||||
return { label: '已超时', tone: 'danger', urgent: true }
|
||||
}
|
||||
|
||||
const diffHours = diffMs / (60 * 60 * 1000)
|
||||
const diffMinutes = Math.max(1, Math.ceil(diffMs / (60 * 1000)))
|
||||
const label = diffHours >= 1 ? `${diffHours.toFixed(diffHours >= 10 ? 0 : 1)}h` : `${diffMinutes}m`
|
||||
if (diffHours <= 2) {
|
||||
return { label, tone: 'danger', urgent: true }
|
||||
}
|
||||
if (diffHours <= 8) {
|
||||
return { label, tone: 'warning', urgent: false }
|
||||
}
|
||||
return { label, tone: 'safe', urgent: false }
|
||||
}
|
||||
|
||||
function buildHeroSummaryItems(request) {
|
||||
return [
|
||||
{ label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' },
|
||||
{ label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' },
|
||||
{ label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' },
|
||||
{ label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFlowItems(request) {
|
||||
return Array.isArray(request?.progressSteps)
|
||||
? request.progressSteps.map((item) => ({
|
||||
label: item.label,
|
||||
desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理',
|
||||
time: item.time,
|
||||
icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline',
|
||||
current: item.current,
|
||||
pending: !item.done && !item.current
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
if (currentName && applicantName && currentName === applicantName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
|
||||
return node.includes('财务')
|
||||
}
|
||||
|
||||
return (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|| node.includes('部门负责人')
|
||||
|| node.includes('负责人审批')
|
||||
)
|
||||
}
|
||||
|
||||
function buildApprovalRow(request) {
|
||||
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
|
||||
const riskItems = resolveRiskItems(request)
|
||||
const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : []
|
||||
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
|
||||
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
|
||||
|
||||
return {
|
||||
...request,
|
||||
applicant: request.person,
|
||||
avatar: String(request.person || '?').trim().slice(0, 1) || '?',
|
||||
department: request.dept,
|
||||
type: request.typeLabel,
|
||||
amount: formatCurrency(request.amount),
|
||||
time: request.applyTime,
|
||||
risk: RISK_LABELS[riskTone] || RISK_LABELS.low,
|
||||
riskTone,
|
||||
sla: slaMeta.label,
|
||||
slaTone: slaMeta.tone,
|
||||
node: request.workflowNode || '审批中',
|
||||
status: statusTone === 'urgent' ? '即将超时' : '待审批',
|
||||
statusTone,
|
||||
spotlight: riskTone === 'high' || statusTone === 'urgent',
|
||||
heroSummaryItems: buildHeroSummaryItems(request),
|
||||
summaryItems: buildHeroSummaryItems(request).slice(2),
|
||||
progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [],
|
||||
expenseItems,
|
||||
attachments: buildAttachments(expenseItems),
|
||||
riskItems,
|
||||
flowItems: buildFlowItems(request)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ApprovalCenterView' ,
|
||||
setup(props, { emit }) {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedRow = ref(null)
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
|
||||
const listKeyword = ref('')
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const rows = [
|
||||
{ id: 'RE240712001', applicant: '李文静', avatar: '李', department: '市场部', type: '差旅报销', amount: '¥3,680', time: '07-12 09:20', risk: '中风险', riskTone: 'medium', sla: '4.2h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
|
||||
{ id: 'RE240712002', applicant: '王志强', avatar: '王', department: '销售部', type: '招待费', amount: '¥1,280', time: '07-12 08:15', risk: '低风险', riskTone: 'low', sla: '8.5h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' },
|
||||
{ id: 'RE240711098', applicant: '刘思雨', avatar: '刘', department: '市场部', type: '差旅报销', amount: '¥6,920', time: '07-11 18:46', risk: '高风险', riskTone: 'high', sla: '0.8h', slaTone: 'danger', node: '财务审批', status: '即将超时', statusTone: 'urgent', spotlight: true },
|
||||
{ id: 'RE240711087', applicant: '陈晓琳', avatar: '陈', department: '行政部', type: '办公采购', amount: '¥860', time: '07-11 17:32', risk: '低风险', riskTone: 'low', sla: '6.1h', slaTone: 'safe', node: '预算校验', status: '待审批', statusTone: 'pending' },
|
||||
{ id: 'RE240711076', applicant: '赵明', avatar: '赵', department: '研发中心', type: '其他费用', amount: '¥4,250', time: '07-11 15:10', risk: '中风险', riskTone: 'medium', sla: '2.4h', slaTone: 'warning', node: '部门负责人', status: '待审批', statusTone: 'pending' },
|
||||
{ id: 'RE240711065', applicant: '孙楠', avatar: '孙', department: '财务部', type: '招待费', amount: '¥560', time: '07-11 13:42', risk: '低风险', riskTone: 'low', sla: '5.7h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
|
||||
{ id: 'RE240711054', applicant: '周晓彤', avatar: '周', department: '市场部', type: '办公采购', amount: '¥2,150', time: '07-11 11:28', risk: '中风险', riskTone: 'medium', sla: '1.9h', slaTone: 'warning', node: '预算校验', status: '即将超时', statusTone: 'urgent' },
|
||||
{ id: 'RE240711043', applicant: '吴磊', avatar: '吴', department: '销售部', type: '其他费用', amount: '¥980', time: '07-11 09:05', risk: '低风险', riskTone: 'low', sla: '7.3h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' }
|
||||
]
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
if (activeTab.value === '全部待审') return rows
|
||||
if (activeTab.value === '高风险') return rows.filter((row) => row.risk === '高风险')
|
||||
if (activeTab.value === '即将超时') return rows.filter((row) => row.status === '即将超时')
|
||||
return rows.slice(0, 3).map((row) => ({ ...row, status: '已处理', statusTone: 'done' }))
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
|
||||
},
|
||||
set(value) {
|
||||
selectedClaimId.value = value?.claimId || ''
|
||||
expandedExpenseId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const approvalSteps = [
|
||||
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
|
||||
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
|
||||
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
|
||||
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
|
||||
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
const visibleRows = computed(() => {
|
||||
let filteredRows = rows.value
|
||||
|
||||
const summaryItems = [
|
||||
{ label: '行程', value: '北京 → 上海', icon: 'mdi mdi-map-marker-path' },
|
||||
{ label: '出差区间', value: '07-10 至 07-11', icon: 'mdi mdi-clock-outline' },
|
||||
{ label: '票据关联', value: '8 条明细 / 7 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
|
||||
{ label: '成本归属', value: '市场部 · CC-MKT-01', icon: 'mdi mdi-account-group-outline' },
|
||||
{ label: '支付方式', value: '企业垫付', icon: 'mdi mdi-credit-card-outline' }
|
||||
]
|
||||
// 根据标签筛选
|
||||
if (activeTab.value === '高风险') {
|
||||
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
|
||||
} else if (activeTab.value === '即将超时') {
|
||||
filteredRows = filteredRows.filter((row) => row.statusTone === 'urgent')
|
||||
} else if (activeTab.value === '已处理') {
|
||||
filteredRows = []
|
||||
}
|
||||
|
||||
const heroSummaryItems = computed(() => [
|
||||
{ label: '单号', value: selectedRow.value?.id ?? '-', icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '报销类型', value: selectedRow.value?.type ?? '-', icon: 'mdi mdi-briefcase-outline' },
|
||||
...summaryItems
|
||||
])
|
||||
// 根据搜索关键词筛选
|
||||
if (listKeyword.value.trim()) {
|
||||
const keyword = listKeyword.value.trim().toLowerCase()
|
||||
filteredRows = filteredRows.filter((row) => {
|
||||
return (
|
||||
String(row.id || '').toLowerCase().includes(keyword) ||
|
||||
String(row.applicant || '').toLowerCase().includes(keyword) ||
|
||||
String(row.department || '').toLowerCase().includes(keyword) ||
|
||||
String(row.type || '').toLowerCase().includes(keyword) ||
|
||||
String(row.amount || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return filteredRows
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
eyebrow: '审批中心',
|
||||
title: '当前没有待审批单据',
|
||||
desc: '进入直属领导或财务审批节点的报销单会自动汇总到这里,后续可继续处理或跟踪。',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
actionLabel: null,
|
||||
actionIcon: null,
|
||||
tone: 'slate',
|
||||
artLabel: 'QUEUE',
|
||||
tips: ['当前仅展示你有权限处理的单据', '高风险和即将超时单据会优先高亮']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: '状态列表为空',
|
||||
title: `“${activeTab.value}”里暂时没有单据`,
|
||||
desc: activeTab.value === '已处理'
|
||||
? '当前视图还没有已处理审批数据,可以先回到全部待审继续处理。'
|
||||
: '可以切换到其他状态查看,或返回全部待审列表继续处理。',
|
||||
icon: activeTab.value === '已处理' ? 'mdi mdi-archive-clock-outline' : 'mdi mdi-view-list-outline',
|
||||
actionLabel: '查看全部待审',
|
||||
actionIcon: 'mdi mdi-format-list-bulleted',
|
||||
tone: activeTab.value === '已处理' ? 'amber' : 'sky',
|
||||
artLabel: activeTab.value === '已处理' ? 'DONE' : 'FILTER',
|
||||
tips: ['分页与表格只在有数据时展示', '空态页面会保留当前页签上下文说明']
|
||||
}
|
||||
})
|
||||
|
||||
const approvalSteps = computed(() => selectedRow.value?.progressSteps || [])
|
||||
const summaryItems = computed(() => selectedRow.value?.summaryItems || [])
|
||||
const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || [])
|
||||
const expenseItems = computed(() => selectedRow.value?.expenseItems || [])
|
||||
const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0))
|
||||
const uploadedExpenseCount = computed(
|
||||
() => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length
|
||||
)
|
||||
const attachments = computed(() => selectedRow.value?.attachments || [])
|
||||
const riskItems = computed(() => selectedRow.value?.riskItems || [])
|
||||
const flowItems = computed(() => selectedRow.value?.flowItems || [])
|
||||
|
||||
const currentProgressRingMotion = {
|
||||
initial: {
|
||||
scale: 1,
|
||||
opacity: 0.34,
|
||||
opacity: 0.34
|
||||
},
|
||||
enter: {
|
||||
scale: [1, 1.42, 1.78],
|
||||
@@ -64,208 +359,68 @@ export default {
|
||||
repeatType: 'loop',
|
||||
repeatDelay: 0.85,
|
||||
ease: 'easeOut',
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
},
|
||||
times: [0, 0.5, 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expenseItems = [
|
||||
{
|
||||
id: 'flight-1',
|
||||
time: '07-10 07:25',
|
||||
dayLabel: '周三',
|
||||
name: '机票',
|
||||
category: '交通',
|
||||
desc: '北京首都 → 上海虹桥',
|
||||
detail: 'MU5103 往返经济舱,含行程单',
|
||||
amount: '¥2,180',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachmentHint: '电子行程单与机票发票齐全',
|
||||
attachments: ['电子行程单.pdf', '机票发票.pdf'],
|
||||
riskLabel: '低风险',
|
||||
riskTone: 'low',
|
||||
riskText: '票面信息与行程匹配。'
|
||||
},
|
||||
{
|
||||
id: 'taxi-1',
|
||||
time: '07-10 10:35',
|
||||
dayLabel: '周三',
|
||||
name: '出租车',
|
||||
category: '市内交通',
|
||||
desc: '虹桥机场 → 静安酒店',
|
||||
detail: '落地后前往酒店,含过路费',
|
||||
amount: '¥86',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachmentHint: '已上传 1 张发票',
|
||||
attachments: ['出租车发票-0710-01.jpg'],
|
||||
riskLabel: '中风险',
|
||||
riskTone: 'medium',
|
||||
riskText: '高峰加价较高,建议顺带核对上车点。'
|
||||
},
|
||||
{
|
||||
id: 'metro-1',
|
||||
time: '07-10 18:20',
|
||||
dayLabel: '周三',
|
||||
name: '地铁',
|
||||
category: '市内交通',
|
||||
desc: '静安酒店 → 客户园区',
|
||||
detail: '2 号线换乘,通勤交通',
|
||||
amount: '¥12',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachmentHint: '已上传电子票据',
|
||||
attachments: ['地铁电子票据-0710.png'],
|
||||
riskLabel: '低风险',
|
||||
riskTone: 'low',
|
||||
riskText: '路线与拜访行程一致。'
|
||||
},
|
||||
{
|
||||
id: 'taxi-2',
|
||||
time: '07-11 08:40',
|
||||
dayLabel: '周四',
|
||||
name: '出租车',
|
||||
category: '市内交通',
|
||||
desc: '静安酒店 → 客户园区',
|
||||
detail: '次日早会前往客户现场',
|
||||
amount: '¥42',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '未上传',
|
||||
attachmentTone: 'missing',
|
||||
attachmentHint: '缺少对应发票',
|
||||
attachments: [],
|
||||
riskLabel: '高风险',
|
||||
riskTone: 'high',
|
||||
riskText: '票据缺失,当前无法完成交通费核验。'
|
||||
},
|
||||
{
|
||||
id: 'taxi-3',
|
||||
time: '07-11 20:55',
|
||||
dayLabel: '周四',
|
||||
name: '出租车',
|
||||
category: '返程交通',
|
||||
desc: '客户园区 → 虹桥机场',
|
||||
detail: '夜间返程,触发超标校验',
|
||||
amount: '¥136',
|
||||
status: '超标 ¥28',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachmentHint: '已上传 1 张发票',
|
||||
attachments: ['出租车发票-0711-02.jpg'],
|
||||
riskLabel: '中风险',
|
||||
riskTone: 'medium',
|
||||
riskText: '金额超差旅标准 ¥28,需补充业务说明。'
|
||||
},
|
||||
{
|
||||
id: 'hotel-1',
|
||||
time: '07-10 至 07-11',
|
||||
dayLabel: '2 晚',
|
||||
name: '酒店',
|
||||
category: '住宿',
|
||||
desc: '上海静安商务酒店',
|
||||
detail: '标准大床房 2 晚,含早餐',
|
||||
amount: '¥2,480',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '部分上传',
|
||||
attachmentTone: 'partial',
|
||||
attachmentHint: '发票已上传,入住清单缺失',
|
||||
attachments: ['酒店发票.jpg'],
|
||||
riskLabel: '高风险',
|
||||
riskTone: 'high',
|
||||
riskText: '缺少入住清单,住宿真实性待补证。'
|
||||
},
|
||||
{
|
||||
id: 'meal-1',
|
||||
time: '07-10 至 07-11',
|
||||
dayLabel: '2 天',
|
||||
name: '餐补',
|
||||
category: '补贴',
|
||||
desc: '差旅餐补',
|
||||
detail: '按差旅制度自动计算',
|
||||
amount: '¥372',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '免上传',
|
||||
attachmentTone: 'neutral',
|
||||
attachmentHint: '制度型补贴无需票据',
|
||||
attachments: [],
|
||||
riskLabel: '低风险',
|
||||
riskTone: 'low',
|
||||
riskText: '系统自动核算,无额外异常。'
|
||||
},
|
||||
{
|
||||
id: 'other-1',
|
||||
time: '07-11 09:10',
|
||||
dayLabel: '周四',
|
||||
name: '其他',
|
||||
category: '杂费',
|
||||
desc: '行李寄存 / 打印费',
|
||||
detail: '客户提案资料打印与寄存服务',
|
||||
amount: '¥1,612',
|
||||
status: '未超标',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachmentHint: '已上传 2 份附件',
|
||||
attachments: ['打印服务发票.jpg', '行李寄存凭证.jpg'],
|
||||
riskLabel: '低风险',
|
||||
riskTone: 'low',
|
||||
riskText: '用途清晰,金额在授权范围内。'
|
||||
}
|
||||
]
|
||||
function showExpenseRisk(item) {
|
||||
return ['medium', 'high'].includes(String(item?.riskTone || '').trim())
|
||||
}
|
||||
|
||||
const expenseTotal = '¥6,920'
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.filter((item) => item.attachments.length).length)
|
||||
|
||||
const showExpenseRisk = (item) => item.riskTone === 'medium' || item.riskTone === 'high'
|
||||
|
||||
const toggleExpenseAttachments = (id) => {
|
||||
function toggleExpenseAttachments(id) {
|
||||
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
||||
}
|
||||
|
||||
const attachments = [
|
||||
{ name: '机票.pdf', size: '256 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
|
||||
{ name: '酒店发票.jpg', size: '412 KB', icon: 'mdi mdi-image', iconClass: 'img' },
|
||||
{ name: '行程单.pdf', size: '198 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
|
||||
{ name: '出租车发票1.jpg', size: '128 KB', icon: 'mdi mdi-image', iconClass: 'img' },
|
||||
{ name: '出租车发票2.jpg', size: '132 KB', icon: 'mdi mdi-image', iconClass: 'img' },
|
||||
{ name: '酒店入住清单', size: '缺失', icon: 'mdi mdi-minus-circle', iconClass: 'miss', missing: true }
|
||||
]
|
||||
function handleEmptyAction() {
|
||||
if (!rows.value.length) {
|
||||
void reload()
|
||||
return
|
||||
}
|
||||
|
||||
const riskItems = [
|
||||
{ text: '酒店入住清单缺失', level: '高', tone: 'high', icon: 'mdi mdi-alert-circle' },
|
||||
{ text: '1 笔出租车费用超差旅标准 ¥28', level: '中', tone: 'medium', icon: 'mdi mdi-alert' },
|
||||
{ text: '发票抬头识别为个人,建议核对', level: '中', tone: 'medium', icon: 'mdi mdi-lightbulb-on' }
|
||||
]
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
const flowItems = [
|
||||
{ label: '提交申请', desc: '刘思雨 提交申请', time: '07-11 08:46', icon: 'mdi mdi-check' },
|
||||
{ label: '票据识别', desc: 'AI 自动识别完成', time: '07-11 08:48', icon: 'mdi mdi-check' },
|
||||
{ label: '费用归类', desc: '费用归类完成', time: '07-11 08:49', icon: 'mdi mdi-check' },
|
||||
{ label: '部门负责人审批', desc: '李文静 已通过', time: '07-11 11:28', icon: 'mdi mdi-check' },
|
||||
{ label: '财务审批', desc: '张晓明 审批中', time: '进行中', icon: 'mdi mdi-circle-slice-8', current: true },
|
||||
{ label: '归档入账', desc: '待处理', time: '-', icon: 'mdi mdi-circle-outline', pending: true }
|
||||
]
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchExpenseClaims()
|
||||
const mappedRows = Array.isArray(payload)
|
||||
? payload
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter((item) => item.approvalKey === 'in_progress')
|
||||
.filter((item) => canCurrentUserProcessRequest(item, currentUser.value))
|
||||
.map((item) => buildApprovalRow(item))
|
||||
: []
|
||||
rows.value = mappedRows
|
||||
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
} catch (nextError) {
|
||||
rows.value = []
|
||||
selectedClaimId.value = ''
|
||||
error.value = nextError instanceof Error ? nextError.message : '审批中心加载失败。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void reload()
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
selectedRow,
|
||||
expandedExpenseId,
|
||||
listKeyword,
|
||||
tabs,
|
||||
filters,
|
||||
rows,
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
@@ -277,8 +432,11 @@ export default {
|
||||
toggleExpenseAttachments,
|
||||
attachments,
|
||||
riskItems,
|
||||
flowItems
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
loading,
|
||||
error,
|
||||
reload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
createAgentAssetVersion,
|
||||
fetchAgentAssetDetail,
|
||||
fetchAgentAssets,
|
||||
fetchAgentRuns
|
||||
fetchAgentRuns,
|
||||
updateAgentAsset
|
||||
} from '../../services/agentAssets.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
|
||||
@@ -120,6 +121,8 @@ const SCENARIO_LABELS = {
|
||||
duplicate_expense: '重复报销',
|
||||
explain: '规则解释',
|
||||
invoice_anomaly: '票据异常',
|
||||
travel_policy: '差旅制度',
|
||||
travel_standard: '差旅标准',
|
||||
accounts_payable: '应付',
|
||||
accounts_receivable: '应收',
|
||||
approval_required: '需审批',
|
||||
@@ -216,10 +219,126 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'disabled', label: '已停用' }
|
||||
]
|
||||
|
||||
const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i
|
||||
|
||||
const RULE_TEMPLATE_LABELS = {
|
||||
travel_standard_v1: '差旅标准模板',
|
||||
expense_amount_limit_v1: '金额上限模板',
|
||||
attachment_requirement_v1: '附件要求模板',
|
||||
general_policy_v1: '通用制度模板'
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readConfigJson(value) {
|
||||
if (isPlainObject(value?.configJson)) {
|
||||
return value.configJson
|
||||
}
|
||||
if (isPlainObject(value?.config_json)) {
|
||||
return value.config_json
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function cloneJsonObject(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch {
|
||||
return { ...value }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuleTemplateLabel(value) {
|
||||
const templateKey = normalizeText(value)
|
||||
return RULE_TEMPLATE_LABELS[templateKey] || templateKey || '未指定模板'
|
||||
}
|
||||
|
||||
function extractRuntimeRuleFromMarkdown(markdown) {
|
||||
const match = String(markdown || '').match(EXPENSE_RULE_BLOCK_PATTERN)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(match[1])
|
||||
return isPlainObject(payload) ? payload : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stripRuntimeRuleBlock(markdown) {
|
||||
const text = String(markdown || '')
|
||||
const stripped = text.replace(EXPENSE_RULE_BLOCK_PATTERN, '').replace(/\n{3,}/g, '\n\n').trim()
|
||||
return stripped
|
||||
}
|
||||
|
||||
function stringifyRuntimeRule(runtimeRule) {
|
||||
return JSON.stringify(isPlainObject(runtimeRule) ? runtimeRule : {}, null, 2)
|
||||
}
|
||||
|
||||
function parseRuntimeRuleText(runtimeRuleText) {
|
||||
const text = normalizeText(runtimeRuleText)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text)
|
||||
return isPlainObject(payload) ? payload : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultRuntimeRule(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const scenarioItems = Array.isArray(source?.scenario_json)
|
||||
? source.scenario_json
|
||||
: Array.isArray(source?.scenarioList)
|
||||
? source.scenarioList
|
||||
: []
|
||||
const configRuntimeRule = cloneJsonObject(configJson.runtime_rule)
|
||||
|
||||
return {
|
||||
kind: normalizeText(configRuntimeRule?.kind || configJson.runtime_kind) || 'policy_rule_draft',
|
||||
version:
|
||||
typeof configRuntimeRule?.version === 'number' && Number.isFinite(configRuntimeRule.version)
|
||||
? configRuntimeRule.version
|
||||
: 1,
|
||||
template_key:
|
||||
normalizeText(configRuntimeRule?.template_key || configJson.rule_template_key) || 'general_policy_v1',
|
||||
rule_name: normalizeText(configRuntimeRule?.rule_name || source?.name) || '未命名规则',
|
||||
scenario:
|
||||
normalizeText(configRuntimeRule?.scenario || scenarioItems[0]) || 'expense',
|
||||
review_required:
|
||||
typeof configRuntimeRule?.review_required === 'boolean' ? configRuntimeRule.review_required : true
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeRuleForVersion(source, rawMarkdown, runtimeRuleFallback = null) {
|
||||
return (
|
||||
cloneJsonObject(extractRuntimeRuleFromMarkdown(rawMarkdown)) ||
|
||||
cloneJsonObject(runtimeRuleFallback) ||
|
||||
buildDefaultRuntimeRule(source)
|
||||
)
|
||||
}
|
||||
|
||||
function buildMarkdownVersionContent(markdownContent, runtimeRule) {
|
||||
const body = stripRuntimeRuleBlock(markdownContent)
|
||||
const runtimeBlock = ['```expense-rule', stringifyRuntimeRule(runtimeRule), '```'].join('\n')
|
||||
return body ? `${body}\n\n${runtimeBlock}` : runtimeBlock
|
||||
}
|
||||
|
||||
function makeShort(value) {
|
||||
const text = normalizeText(value).replace(/\s+/g, '')
|
||||
if (!text) {
|
||||
@@ -273,16 +392,27 @@ function formatScenarioList(items) {
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
function buildHistory(recentVersions = []) {
|
||||
return recentVersions.map((item) => ({
|
||||
version: item.version,
|
||||
note: item.change_note || '无版本说明',
|
||||
time: formatDateTime(item.created_at),
|
||||
content: item.content,
|
||||
contentType: item.content_type,
|
||||
createdBy: item.created_by,
|
||||
isCurrent: Boolean(item.is_current)
|
||||
}))
|
||||
function buildHistory(recentVersions = [], source) {
|
||||
const currentRuntimeRule = cloneJsonObject(readConfigJson(source).runtime_rule)
|
||||
|
||||
return recentVersions.map((item) => {
|
||||
const rawContent = typeof item.content === 'string' ? item.content : ''
|
||||
return {
|
||||
version: item.version,
|
||||
note: item.change_note || '无版本说明',
|
||||
time: formatDateTime(item.created_at),
|
||||
content: rawContent,
|
||||
markdownContent: stripRuntimeRuleBlock(rawContent),
|
||||
runtimeRule: resolveRuntimeRuleForVersion(
|
||||
source,
|
||||
rawContent,
|
||||
item.is_current ? currentRuntimeRule : null
|
||||
),
|
||||
contentType: item.content_type,
|
||||
createdBy: item.created_by,
|
||||
isCurrent: Boolean(item.is_current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTypeKey(assetType) {
|
||||
@@ -423,7 +553,15 @@ function buildListItem(asset) {
|
||||
function buildRuleFields(detail) {
|
||||
return [
|
||||
{ label: '规则编码', value: detail.code },
|
||||
{
|
||||
label: '模板键',
|
||||
value: normalizeText(detail.config_json?.rule_template_key) || '未指定'
|
||||
},
|
||||
{ label: '业务域', value: resolveDomainLabel(detail.domain) },
|
||||
{
|
||||
label: '运行时类型',
|
||||
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
|
||||
},
|
||||
{ label: '适用场景', value: formatScenarioList(detail.scenario_json) },
|
||||
{ label: '当前版本', value: detail.current_version || '-' }
|
||||
]
|
||||
@@ -555,7 +693,8 @@ function buildOutputRules(detail, typeKey, latestRun, latestCall) {
|
||||
|
||||
if (typeKey === 'rules') {
|
||||
return [
|
||||
'规则 Markdown 保存后会生成新版本。',
|
||||
'规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。',
|
||||
'保存 Markdown 或 JSON 都会生成新版本快照。',
|
||||
'未审核通过的规则版本不能正式上线。',
|
||||
'版本切换当前只影响前端展示内容,不会直接回滚后端版本。'
|
||||
]
|
||||
@@ -730,15 +869,26 @@ function buildDetailViewModel(detail, runs) {
|
||||
const typeKey = resolveTypeKey(detail.asset_type)
|
||||
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)
|
||||
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
|
||||
const history = buildHistory(detail.recent_versions || [])
|
||||
const history = buildHistory(detail.recent_versions || [], detail)
|
||||
const previewVersion = history.find((item) => item.isCurrent) || history[0] || null
|
||||
const previewMarkdown =
|
||||
const previewRawMarkdown =
|
||||
detail.current_version_content_type === 'markdown'
|
||||
? String(previewVersion?.content ?? detail.current_version_content ?? '')
|
||||
: ''
|
||||
const previewRuntimeRule = resolveRuntimeRuleForVersion(
|
||||
detail,
|
||||
previewRawMarkdown,
|
||||
previewVersion?.runtimeRule || configJson.runtime_rule
|
||||
)
|
||||
const previewMarkdown = stripRuntimeRuleBlock(previewRawMarkdown)
|
||||
const titles = DETAIL_TITLES[typeKey]
|
||||
const previewChangeNote = previewVersion?.note || detail.current_version_change_note || '无版本说明'
|
||||
const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key)
|
||||
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
|
||||
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
@@ -761,9 +911,16 @@ function buildDetailViewModel(detail, runs) {
|
||||
hitRate: buildRowMetric(detail, typeKey),
|
||||
updatedAt: formatDateTime(detail.updated_at),
|
||||
badgeTone: BADGE_TONES[typeKey],
|
||||
configJson,
|
||||
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
|
||||
markdownContent: previewMarkdown,
|
||||
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
|
||||
ruleTemplateKey,
|
||||
ruleTemplateLabel,
|
||||
runtimeKind,
|
||||
currentVersionContentType: detail.current_version_content_type,
|
||||
currentVersionChangeNote: detail.current_version_change_note || '无版本说明',
|
||||
displayVersionChangeNote: previewChangeNote,
|
||||
reviewStatusLabel: reviewMeta.label,
|
||||
reviewStatusTone: reviewMeta.tone,
|
||||
reviewStatusValue: detail.latest_review?.review_status || '',
|
||||
@@ -1090,6 +1247,30 @@ export default {
|
||||
return currentUser.value?.name || currentUser.value?.username || 'system'
|
||||
}
|
||||
|
||||
function buildRuleConfigPayload(asset, runtimeRule) {
|
||||
const configJson = {
|
||||
...readConfigJson(asset),
|
||||
runtime_kind: normalizeText(runtimeRule?.kind) || asset.runtimeKind || 'policy_rule_draft',
|
||||
runtime_rule: runtimeRule
|
||||
}
|
||||
const templateKey = normalizeText(runtimeRule?.template_key) || asset.ruleTemplateKey
|
||||
if (templateKey) {
|
||||
configJson.rule_template_key = templateKey
|
||||
configJson.rule_template_label = resolveRuleTemplateLabel(templateKey)
|
||||
}
|
||||
return configJson
|
||||
}
|
||||
|
||||
async function persistRuleRuntimeConfig(asset, runtimeRule) {
|
||||
await updateAgentAsset(
|
||||
asset.id,
|
||||
{
|
||||
config_json: buildRuleConfigPayload(asset, runtimeRule)
|
||||
},
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
}
|
||||
|
||||
async function loadRuns(options = {}) {
|
||||
if (runLoading.value && !options.force) {
|
||||
return
|
||||
@@ -1153,6 +1334,8 @@ export default {
|
||||
function openAssetDetail(asset) {
|
||||
selectedSkill.value = {
|
||||
...asset,
|
||||
configJson: {},
|
||||
scenarioList: [],
|
||||
fields: [],
|
||||
promptSections: [],
|
||||
outputRules: [],
|
||||
@@ -1161,7 +1344,12 @@ export default {
|
||||
tools: [],
|
||||
history: [],
|
||||
markdownContent: '',
|
||||
runtimeRuleText: '',
|
||||
ruleTemplateKey: '',
|
||||
ruleTemplateLabel: '',
|
||||
runtimeKind: 'policy_rule_draft',
|
||||
displayVersion: asset.version,
|
||||
displayVersionChangeNote: '无版本说明',
|
||||
loading: true,
|
||||
reviewStatusLabel: '加载中',
|
||||
reviewStatusTone: 'draft'
|
||||
@@ -1194,9 +1382,17 @@ export default {
|
||||
}
|
||||
|
||||
selectedSkill.value.displayVersion = versionSwitchTarget.value.version
|
||||
if (typeof versionSwitchTarget.value.content === 'string') {
|
||||
selectedSkill.value.markdownContent = versionSwitchTarget.value.content
|
||||
selectedSkill.value.displayVersionChangeNote = versionSwitchTarget.value.note || '无版本说明'
|
||||
if (typeof versionSwitchTarget.value.markdownContent === 'string') {
|
||||
selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent
|
||||
}
|
||||
const runtimeRule = versionSwitchTarget.value.runtimeRule || buildDefaultRuntimeRule(selectedSkill.value)
|
||||
selectedSkill.value.runtimeRuleText = stringifyRuntimeRule(runtimeRule)
|
||||
selectedSkill.value.runtimeKind =
|
||||
normalizeText(runtimeRule.kind) || selectedSkill.value.runtimeKind || 'policy_rule_draft'
|
||||
selectedSkill.value.ruleTemplateKey =
|
||||
normalizeText(runtimeRule.template_key) || selectedSkill.value.ruleTemplateKey
|
||||
selectedSkill.value.ruleTemplateLabel = resolveRuleTemplateLabel(selectedSkill.value.ruleTemplateKey)
|
||||
versionSwitchTarget.value = null
|
||||
}
|
||||
|
||||
@@ -1210,6 +1406,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText)
|
||||
if (!runtimeRule) {
|
||||
toast('运行时 JSON 必须是合法的对象。')
|
||||
return
|
||||
}
|
||||
|
||||
const nextVersion = incrementVersion(selectedSkill.value.currentVersion)
|
||||
actionState.value = 'save-markdown'
|
||||
|
||||
@@ -1218,13 +1420,14 @@ export default {
|
||||
selectedSkill.value.id,
|
||||
{
|
||||
version: nextVersion,
|
||||
content: selectedSkill.value.markdownContent,
|
||||
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
|
||||
content_type: 'markdown',
|
||||
change_note: '通过任务规则中心保存 Markdown 规则内容。',
|
||||
change_note: '通过任务规则中心保存 Markdown 规则内容,并同步运行时 JSON。',
|
||||
created_by: resolveActor()
|
||||
},
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
toast(`规则 Markdown 已保存为 ${nextVersion}。`)
|
||||
@@ -1235,6 +1438,48 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRuleRuntimeJson() {
|
||||
if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeText(selectedSkill.value.markdownContent)) {
|
||||
toast('规则 Markdown 模板不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText)
|
||||
if (!runtimeRule) {
|
||||
toast('运行时 JSON 必须是合法的对象。')
|
||||
return
|
||||
}
|
||||
|
||||
const nextVersion = incrementVersion(selectedSkill.value.currentVersion)
|
||||
actionState.value = 'save-runtime-json'
|
||||
|
||||
try {
|
||||
await createAgentAssetVersion(
|
||||
selectedSkill.value.id,
|
||||
{
|
||||
version: nextVersion,
|
||||
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
|
||||
content_type: 'markdown',
|
||||
change_note: '通过任务规则中心保存运行时 JSON 配置。',
|
||||
created_by: resolveActor()
|
||||
},
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
toast(`规则 JSON 已保存为 ${nextVersion}。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || '规则 JSON 保存失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewSelectedRule(reviewStatus) {
|
||||
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
|
||||
return
|
||||
@@ -1340,6 +1585,7 @@ export default {
|
||||
cancelVersionSwitch,
|
||||
confirmVersionSwitch,
|
||||
saveRuleMarkdown,
|
||||
saveRuleRuntimeJson,
|
||||
reviewSelectedRule,
|
||||
activateSelectedRule,
|
||||
loadAssets
|
||||
|
||||
@@ -3,14 +3,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
deleteKnowledgeDocument,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeDocumentBlob,
|
||||
fetchKnowledgeLibrary,
|
||||
fetchKnowledgeOnlyOfficeConfig,
|
||||
uploadKnowledgeDocument
|
||||
} from '../../services/knowledge.js'
|
||||
import {
|
||||
deleteKnowledgeDocument,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeDocumentBlob,
|
||||
fetchLlmWikiDocumentDetail,
|
||||
fetchKnowledgeLibrary,
|
||||
fetchKnowledgeOnlyOfficeConfig,
|
||||
syncKnowledgeDocumentToLlmWiki,
|
||||
updateLlmWikiDocumentSummary,
|
||||
uploadKnowledgeDocument
|
||||
} from '../../services/knowledge.js'
|
||||
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
import {
|
||||
@@ -97,6 +100,7 @@ export default {
|
||||
const uploadInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const deletingId = ref('')
|
||||
const ingestingId = ref('')
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTargetDocument = ref(null)
|
||||
const previewLoading = ref(false)
|
||||
@@ -110,6 +114,13 @@ export default {
|
||||
const onlyOfficeReadyTimeoutId = ref(0)
|
||||
const currentPreviewPageIndex = ref(0)
|
||||
const previewDialogPanel = ref(null)
|
||||
const llmWikiDialogOpen = ref(false)
|
||||
const llmWikiDialogPanel = ref(null)
|
||||
const llmWikiLoading = ref(false)
|
||||
const llmWikiSaving = ref(false)
|
||||
const llmWikiError = ref('')
|
||||
const llmWikiDocument = ref(null)
|
||||
const llmWikiSummaryDraft = ref('')
|
||||
|
||||
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
||||
const uploadHint = computed(() =>
|
||||
@@ -275,7 +286,13 @@ export default {
|
||||
closePreview()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.preserveSelection && llmWikiDocument.value?.document_id) {
|
||||
const exists = documents.value.some((doc) => doc.id === llmWikiDocument.value.document_id)
|
||||
if (!exists) {
|
||||
closeLlmWikiSummary()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emit('summary-change', { totalDocuments: 0 })
|
||||
toast(error.message || '知识库加载失败。')
|
||||
} finally {
|
||||
@@ -314,18 +331,167 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(document) {
|
||||
try {
|
||||
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
|
||||
triggerFileDownload(blob, document.name)
|
||||
async function handleDownload(document) {
|
||||
try {
|
||||
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
|
||||
triggerFileDownload(blob, document.name)
|
||||
} catch (error) {
|
||||
toast(error.message || '下载失败。')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
if (!isAdmin.value || uploading.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function patchDocumentState(documentId, patch) {
|
||||
documents.value = documents.value.map((doc) =>
|
||||
doc.id === documentId ? { ...doc, ...patch } : doc
|
||||
)
|
||||
|
||||
if (selectedDocument.value?.id === documentId) {
|
||||
selectedDocument.value = {
|
||||
...selectedDocument.value,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIngestActionLabel(document) {
|
||||
if (ingestingId.value === document.id) {
|
||||
return '归纳中'
|
||||
}
|
||||
return Number(document?.stateCode || 0) === 3 ? '重新归纳' : '归纳'
|
||||
}
|
||||
|
||||
function resolveIngestActionTitle(document) {
|
||||
const action = resolveIngestActionLabel(document)
|
||||
if (action === '归纳中') {
|
||||
return 'Hermes 正在将当前文档归纳到 LLM Wiki'
|
||||
}
|
||||
if (action === '重新归纳') {
|
||||
return '重新使用 Hermes 归纳当前文档到 LLM Wiki'
|
||||
}
|
||||
return '使用 Hermes 归纳当前文档到 LLM Wiki'
|
||||
}
|
||||
|
||||
function canViewLlmWiki(document) {
|
||||
return isAdmin.value && Number(document?.stateCode || 0) === 3
|
||||
}
|
||||
|
||||
function resolveViewLlmWikiTitle(document) {
|
||||
if (!isAdmin.value) {
|
||||
return '仅管理员可查看 LLM Wiki 归纳内容'
|
||||
}
|
||||
if (Number(document?.stateCode || 0) === 2) {
|
||||
return 'Hermes 正在归纳当前文档,完成后可查看 LLM Wiki 知识总结'
|
||||
}
|
||||
if (Number(document?.stateCode || 0) === 4) {
|
||||
return '当前文档上次归纳失败,请重新归纳后再查看'
|
||||
}
|
||||
if (Number(document?.stateCode || 0) !== 3) {
|
||||
return '文档尚未完成归纳,暂无可查看的 LLM Wiki 知识总结'
|
||||
}
|
||||
return '查看并编辑当前文档的 LLM Wiki 归纳内容'
|
||||
}
|
||||
|
||||
async function handleManualIngest(document) {
|
||||
if (!isAdmin.value || ingestingId.value || !document?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
ingestingId.value = document.id
|
||||
patchDocumentState(document.id, {
|
||||
stateCode: 2,
|
||||
state: '正归纳',
|
||||
stateTone: 'warning'
|
||||
})
|
||||
|
||||
try {
|
||||
const payload = await syncKnowledgeDocumentToLlmWiki({
|
||||
folder: document.folder,
|
||||
documentId: document.id
|
||||
})
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
if (selectedDocument.value?.id === document.id) {
|
||||
await selectDocument(document.id)
|
||||
}
|
||||
toast(payload.summary || 'Hermes 已完成文档归纳。')
|
||||
} catch (error) {
|
||||
patchDocumentState(document.id, {
|
||||
stateCode: 4,
|
||||
state: '归纳失败',
|
||||
stateTone: 'danger'
|
||||
})
|
||||
toast(error.message || 'Hermes 归纳文档失败。')
|
||||
} finally {
|
||||
ingestingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function openLlmWikiSummary(document) {
|
||||
if (!canViewLlmWiki(document) || llmWikiLoading.value || !document?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
llmWikiDialogOpen.value = true
|
||||
llmWikiLoading.value = true
|
||||
llmWikiError.value = ''
|
||||
llmWikiDocument.value = null
|
||||
llmWikiSummaryDraft.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchLlmWikiDocumentDetail(document.id)
|
||||
llmWikiDocument.value = payload
|
||||
llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || ''
|
||||
await nextTick()
|
||||
llmWikiDialogPanel.value?.focus?.()
|
||||
} catch (error) {
|
||||
llmWikiError.value = error.message || 'LLM Wiki 归纳内容加载失败。'
|
||||
toast(llmWikiError.value)
|
||||
} finally {
|
||||
llmWikiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeLlmWikiSummary() {
|
||||
if (llmWikiSaving.value) {
|
||||
return
|
||||
}
|
||||
|
||||
llmWikiDialogOpen.value = false
|
||||
llmWikiLoading.value = false
|
||||
llmWikiError.value = ''
|
||||
llmWikiDocument.value = null
|
||||
llmWikiSummaryDraft.value = ''
|
||||
}
|
||||
|
||||
async function saveLlmWikiSummary() {
|
||||
if (!isAdmin.value || !llmWikiDocument.value?.document_id || llmWikiSaving.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const summaryText = String(llmWikiSummaryDraft.value || '').trim()
|
||||
if (!summaryText) {
|
||||
toast('知识总结不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
llmWikiSaving.value = true
|
||||
try {
|
||||
const payload = await updateLlmWikiDocumentSummary(llmWikiDocument.value.document_id, {
|
||||
knowledge_summary_markdown: summaryText
|
||||
})
|
||||
llmWikiDocument.value = payload
|
||||
llmWikiError.value = ''
|
||||
llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || summaryText
|
||||
toast('LLM Wiki 知识总结已保存。')
|
||||
} catch (error) {
|
||||
toast(error.message || 'LLM Wiki 知识总结保存失败。')
|
||||
} finally {
|
||||
llmWikiSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
if (!isAdmin.value || uploading.value) {
|
||||
return
|
||||
}
|
||||
uploadInput.value?.click()
|
||||
}
|
||||
@@ -403,6 +569,9 @@ export default {
|
||||
if (selectedDocument.value?.id === document.id) {
|
||||
closePreview()
|
||||
}
|
||||
if (llmWikiDocument.value?.document_id === document.id) {
|
||||
closeLlmWikiSummary()
|
||||
}
|
||||
await loadLibrary()
|
||||
toast('知识库文件已删除。')
|
||||
} catch (error) {
|
||||
@@ -431,6 +600,10 @@ export default {
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event) {
|
||||
if (event.key === 'Escape' && llmWikiDialogOpen.value) {
|
||||
closeLlmWikiSummary()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && selectedDocument.value) {
|
||||
closePreview()
|
||||
}
|
||||
@@ -451,14 +624,21 @@ export default {
|
||||
|
||||
watch(activeFolder, () => {
|
||||
closePreview()
|
||||
closeLlmWikiSummary()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => previewLayoutState.value.isPreviewModalOpen,
|
||||
async (isPreviewModalOpen) => {
|
||||
setBodyScrollLocked(isPreviewModalOpen)
|
||||
() => previewLayoutState.value.isPreviewModalOpen || llmWikiDialogOpen.value,
|
||||
async (isAnyOverlayOpen) => {
|
||||
setBodyScrollLocked(isAnyOverlayOpen)
|
||||
|
||||
if (isPreviewModalOpen) {
|
||||
if (llmWikiDialogOpen.value) {
|
||||
await nextTick()
|
||||
llmWikiDialogPanel.value?.focus?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (previewLayoutState.value.isPreviewModalOpen) {
|
||||
await nextTick()
|
||||
previewDialogPanel.value?.focus?.()
|
||||
}
|
||||
@@ -483,6 +663,8 @@ export default {
|
||||
changePageSize,
|
||||
closePreview,
|
||||
closeDeleteDialog,
|
||||
closeLlmWikiSummary,
|
||||
canViewLlmWiki,
|
||||
confirmDeleteDocument,
|
||||
excelPreviewTable,
|
||||
currentPage,
|
||||
@@ -490,14 +672,24 @@ export default {
|
||||
deleteDialogOpen,
|
||||
deleteTargetDocument,
|
||||
deletingId,
|
||||
documentSearch,
|
||||
filteredFolders,
|
||||
handleDelete,
|
||||
handleDownload,
|
||||
handleDrop,
|
||||
handleFileInput,
|
||||
isAdmin,
|
||||
loading,
|
||||
documentSearch,
|
||||
filteredFolders,
|
||||
handleDelete,
|
||||
handleDownload,
|
||||
handleDrop,
|
||||
handleFileInput,
|
||||
handleManualIngest,
|
||||
ingestingId,
|
||||
isAdmin,
|
||||
llmWikiDialogOpen,
|
||||
llmWikiDialogPanel,
|
||||
llmWikiDocument,
|
||||
llmWikiError,
|
||||
llmWikiLoading,
|
||||
llmWikiSaving,
|
||||
llmWikiSummaryDraft,
|
||||
loading,
|
||||
openLlmWikiSummary,
|
||||
pageSize,
|
||||
pageSizeOpen,
|
||||
pageSizes,
|
||||
@@ -515,12 +707,16 @@ export default {
|
||||
shouldRenderOnlyOffice,
|
||||
shouldRenderOnlyOfficeHostNode,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
totalCount,
|
||||
totalPages,
|
||||
triggerUpload,
|
||||
uploadHint,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
resolveIngestActionLabel,
|
||||
resolveIngestActionTitle,
|
||||
resolveViewLlmWikiTitle,
|
||||
saveLlmWikiSummary,
|
||||
totalCount,
|
||||
totalPages,
|
||||
triggerUpload,
|
||||
uploadHint,
|
||||
uploadInput,
|
||||
uploading,
|
||||
visibleDocuments
|
||||
|
||||
@@ -1093,8 +1093,16 @@ export default {
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
await submitExpenseClaim(request.value.claimId)
|
||||
toast(`${request.value.id} 已提交审批。`)
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||||
if (claimStatus === 'submitted') {
|
||||
toast(`${request.value.id} 已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
} else if (claimStatus === 'supplement') {
|
||||
toast(`${request.value.id} AI验审未通过,已转待补充。`)
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||
|
||||
Reference in New Issue
Block a user