feat: 添加风险规则及 agent assets 功能增强
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,261 +1,261 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="shared-confirm">
|
||||
<div
|
||||
v-if="open"
|
||||
class="shared-confirm-mask"
|
||||
role="presentation"
|
||||
@click.self="handleMaskClose"
|
||||
>
|
||||
<section
|
||||
class="shared-confirm-card"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
@click.stop
|
||||
>
|
||||
<span v-if="badge" class="shared-confirm-badge" :class="badgeTone">
|
||||
{{ badge }}
|
||||
</span>
|
||||
<h4 :id="titleId">{{ title }}</h4>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
|
||||
<div v-if="$slots.default" class="shared-confirm-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="shared-confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn cancel"
|
||||
:disabled="busy"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn confirm"
|
||||
:class="confirmTone"
|
||||
:disabled="busy"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
|
||||
<span>{{ busy ? busyText : confirmText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
badgeTone: { type: String, default: 'info' },
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
confirmText: { type: String, default: '确认' },
|
||||
busyText: { type: String, default: '处理中...' },
|
||||
confirmTone: { type: String, default: 'primary' },
|
||||
confirmIcon: { type: String, default: '' },
|
||||
busy: { type: Boolean, default: false },
|
||||
closeOnMask: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'cancel', 'confirm'])
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
|
||||
|
||||
function handleMaskClose() {
|
||||
if (!props.closeOnMask || props.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (props.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('cancel')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shared-confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10020;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.shared-confirm-card {
|
||||
width: min(480px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.14);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
|
||||
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.shared-confirm-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.shared-confirm-card h4 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.shared-confirm-card p {
|
||||
margin: 0;
|
||||
color: #5b6b83;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.shared-confirm-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shared-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shared-confirm-btn {
|
||||
min-width: 140px;
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.cancel {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm {
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm.primary {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.22);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm.danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
box-shadow: 0 12px 24px rgba(220, 38, 38, 0.22);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.cancel:hover:not(:disabled) {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shared-confirm-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-active,
|
||||
.shared-confirm-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-from,
|
||||
.shared-confirm-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-from .shared-confirm-card,
|
||||
.shared-confirm-leave-to .shared-confirm-card {
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shared-confirm-mask {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.shared-confirm-card {
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.shared-confirm-card h4 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.shared-confirm-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shared-confirm-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="shared-confirm">
|
||||
<div
|
||||
v-if="open"
|
||||
class="shared-confirm-mask"
|
||||
role="presentation"
|
||||
@click.self="handleMaskClose"
|
||||
>
|
||||
<section
|
||||
class="shared-confirm-card"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
@click.stop
|
||||
>
|
||||
<span v-if="badge" class="shared-confirm-badge" :class="badgeTone">
|
||||
{{ badge }}
|
||||
</span>
|
||||
<h4 :id="titleId">{{ title }}</h4>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
|
||||
<div v-if="$slots.default" class="shared-confirm-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="shared-confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn cancel"
|
||||
:disabled="busy"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn confirm"
|
||||
:class="confirmTone"
|
||||
:disabled="busy"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
|
||||
<span>{{ busy ? busyText : confirmText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
badgeTone: { type: String, default: 'info' },
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
confirmText: { type: String, default: '确认' },
|
||||
busyText: { type: String, default: '处理中...' },
|
||||
confirmTone: { type: String, default: 'primary' },
|
||||
confirmIcon: { type: String, default: '' },
|
||||
busy: { type: Boolean, default: false },
|
||||
closeOnMask: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'cancel', 'confirm'])
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
|
||||
|
||||
function handleMaskClose() {
|
||||
if (!props.closeOnMask || props.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (props.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('cancel')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shared-confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10020;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.shared-confirm-card {
|
||||
width: min(480px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.14);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
|
||||
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.shared-confirm-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.shared-confirm-badge.danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.shared-confirm-card h4 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.shared-confirm-card p {
|
||||
margin: 0;
|
||||
color: #5b6b83;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.shared-confirm-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shared-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shared-confirm-btn {
|
||||
min-width: 140px;
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.cancel {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm {
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm.primary {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.22);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm.danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
box-shadow: 0 12px 24px rgba(220, 38, 38, 0.22);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.cancel:hover:not(:disabled) {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shared-confirm-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-active,
|
||||
.shared-confirm-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-from,
|
||||
.shared-confirm-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shared-confirm-enter-from .shared-confirm-card,
|
||||
.shared-confirm-leave-to .shared-confirm-card {
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shared-confirm-mask {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.shared-confirm-card {
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.shared-confirm-card h4 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.shared-confirm-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shared-confirm-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,310 +1,310 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function hasMissingAttachment(request) {
|
||||
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
|
||||
|
||||
if (expenseItems.length) {
|
||||
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
|
||||
}
|
||||
|
||||
const attachmentSummary = String(request?.attachmentSummary || '').trim()
|
||||
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
|
||||
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
|
||||
}
|
||||
|
||||
function hasPendingInfo(request) {
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return [
|
||||
request.profileDepartment,
|
||||
request.profilePosition,
|
||||
request.profileGrade,
|
||||
request.profileManager,
|
||||
request.reason,
|
||||
request.occurredDisplay
|
||||
].some(isPlaceholderValue)
|
||||
}
|
||||
|
||||
function resolveDetailAlertTone(request) {
|
||||
if (request?.approvalKey === 'completed') return 'success'
|
||||
if (request?.approvalKey === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function buildDetailAlerts(request) {
|
||||
if (!request) {
|
||||
return []
|
||||
}
|
||||
|
||||
const alerts = []
|
||||
const nodeLabel = String(request.node || request.approval || '').trim()
|
||||
|
||||
if (nodeLabel) {
|
||||
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
|
||||
}
|
||||
|
||||
if (hasMissingAttachment(request)) {
|
||||
alerts.push({ label: '缺少票据', tone: 'warning' })
|
||||
}
|
||||
|
||||
if (hasPendingInfo(request)) {
|
||||
alerts.push({ label: '待补信息', tone: 'warning' })
|
||||
}
|
||||
|
||||
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const smartEntryOpen = ref(false)
|
||||
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
|
||||
const smartEntrySessionId = ref(0)
|
||||
|
||||
const { activeView, currentView, setView } = useNavigation()
|
||||
const {
|
||||
requests,
|
||||
loading: requestsLoading,
|
||||
error: requestsError,
|
||||
search,
|
||||
filters,
|
||||
ranges,
|
||||
activeRange,
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
|
||||
const selectedRequest = computed(() => {
|
||||
const requestId = String(route.params.requestId || '')
|
||||
|
||||
if (!requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawRequest = requests.value.find(
|
||||
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
||||
)
|
||||
return normalizeRequestForUi(rawRequest)
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-request-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const topBarView = computed(() => {
|
||||
if (detailMode.value) {
|
||||
return {
|
||||
title: '报销单详情',
|
||||
desc: '查看报销明细、票据材料、审批进度与风险提示。'
|
||||
}
|
||||
}
|
||||
|
||||
if (logDetailMode.value) {
|
||||
return {
|
||||
title: '日志详情',
|
||||
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
|
||||
}
|
||||
}
|
||||
|
||||
return currentView.value
|
||||
})
|
||||
|
||||
const requestSummary = computed(() =>
|
||||
filteredRequests.value.reduce(
|
||||
(summary, item) => {
|
||||
const request = normalizeRequestForUi(item)
|
||||
if (!request) {
|
||||
return summary
|
||||
}
|
||||
|
||||
summary.total += 1
|
||||
|
||||
if (request.approvalKey === 'draft') {
|
||||
summary.draft += 1
|
||||
} else if (request.approvalKey === 'in_progress') {
|
||||
summary.inProgress += 1
|
||||
} else if (request.approvalKey === 'supplement') {
|
||||
summary.supplement += 1
|
||||
} else if (request.approvalKey === 'completed') {
|
||||
summary.completed += 1
|
||||
}
|
||||
|
||||
return summary
|
||||
},
|
||||
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
|
||||
)
|
||||
)
|
||||
|
||||
function handleApprove(request) {
|
||||
const message = approveRequest(request)
|
||||
toast(message)
|
||||
}
|
||||
|
||||
function handleReject(request) {
|
||||
const message = rejectRequest(request)
|
||||
toast(message)
|
||||
}
|
||||
|
||||
function handleNavigate(view) {
|
||||
smartEntryOpen.value = false
|
||||
setView(view)
|
||||
}
|
||||
|
||||
function openTravelCreate() {
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
async function resolveSmartEntryConversation(payload = {}) {
|
||||
if (payload.conversation) {
|
||||
return payload.conversation
|
||||
}
|
||||
|
||||
if (!payload.restoreLatestConversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
|
||||
preferRecoverable: true
|
||||
})
|
||||
return latestPayload?.found ? latestPayload.conversation || null : null
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore latest expense conversation for smart entry:', error)
|
||||
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSmartEntry(payload = {}) {
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
smartEntryOpen.value = true
|
||||
|
||||
smartEntryContext.value = {
|
||||
prompt: payload.prompt ?? '',
|
||||
source: payload.source ?? 'workbench',
|
||||
request: payload.request ?? selectedRequest.value,
|
||||
files: Array.isArray(payload.files) ? payload.files : [],
|
||||
conversation
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function closeSmartEntry() {
|
||||
smartEntryOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDraftSaved(payload = {}) {
|
||||
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
|
||||
const status = String(payload.status || payload.claimStatus || '').trim()
|
||||
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
||||
smartEntryOpen.value = false
|
||||
await reloadRequests()
|
||||
if (status === 'submitted') {
|
||||
toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
} else {
|
||||
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
|
||||
}
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
function openRequestDetail(request) {
|
||||
router.push({
|
||||
name: 'app-request-detail',
|
||||
params: { requestId: request.claimId || request.id }
|
||||
})
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
}
|
||||
|
||||
async function handleRequestDeleted() {
|
||||
await reloadRequests()
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
return {
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
}
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function hasMissingAttachment(request) {
|
||||
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
|
||||
|
||||
if (expenseItems.length) {
|
||||
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
|
||||
}
|
||||
|
||||
const attachmentSummary = String(request?.attachmentSummary || '').trim()
|
||||
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
|
||||
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
|
||||
}
|
||||
|
||||
function hasPendingInfo(request) {
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return [
|
||||
request.profileDepartment,
|
||||
request.profilePosition,
|
||||
request.profileGrade,
|
||||
request.profileManager,
|
||||
request.reason,
|
||||
request.occurredDisplay
|
||||
].some(isPlaceholderValue)
|
||||
}
|
||||
|
||||
function resolveDetailAlertTone(request) {
|
||||
if (request?.approvalKey === 'completed') return 'success'
|
||||
if (request?.approvalKey === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function buildDetailAlerts(request) {
|
||||
if (!request) {
|
||||
return []
|
||||
}
|
||||
|
||||
const alerts = []
|
||||
const nodeLabel = String(request.node || request.approval || '').trim()
|
||||
|
||||
if (nodeLabel) {
|
||||
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
|
||||
}
|
||||
|
||||
if (hasMissingAttachment(request)) {
|
||||
alerts.push({ label: '缺少票据', tone: 'warning' })
|
||||
}
|
||||
|
||||
if (hasPendingInfo(request)) {
|
||||
alerts.push({ label: '待补信息', tone: 'warning' })
|
||||
}
|
||||
|
||||
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const smartEntryOpen = ref(false)
|
||||
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
|
||||
const smartEntrySessionId = ref(0)
|
||||
|
||||
const { activeView, currentView, setView } = useNavigation()
|
||||
const {
|
||||
requests,
|
||||
loading: requestsLoading,
|
||||
error: requestsError,
|
||||
search,
|
||||
filters,
|
||||
ranges,
|
||||
activeRange,
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
|
||||
const selectedRequest = computed(() => {
|
||||
const requestId = String(route.params.requestId || '')
|
||||
|
||||
if (!requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawRequest = requests.value.find(
|
||||
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
||||
)
|
||||
return normalizeRequestForUi(rawRequest)
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-request-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const topBarView = computed(() => {
|
||||
if (detailMode.value) {
|
||||
return {
|
||||
title: '报销单详情',
|
||||
desc: '查看报销明细、票据材料、审批进度与风险提示。'
|
||||
}
|
||||
}
|
||||
|
||||
if (logDetailMode.value) {
|
||||
return {
|
||||
title: '日志详情',
|
||||
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
|
||||
}
|
||||
}
|
||||
|
||||
return currentView.value
|
||||
})
|
||||
|
||||
const requestSummary = computed(() =>
|
||||
filteredRequests.value.reduce(
|
||||
(summary, item) => {
|
||||
const request = normalizeRequestForUi(item)
|
||||
if (!request) {
|
||||
return summary
|
||||
}
|
||||
|
||||
summary.total += 1
|
||||
|
||||
if (request.approvalKey === 'draft') {
|
||||
summary.draft += 1
|
||||
} else if (request.approvalKey === 'in_progress') {
|
||||
summary.inProgress += 1
|
||||
} else if (request.approvalKey === 'supplement') {
|
||||
summary.supplement += 1
|
||||
} else if (request.approvalKey === 'completed') {
|
||||
summary.completed += 1
|
||||
}
|
||||
|
||||
return summary
|
||||
},
|
||||
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
|
||||
)
|
||||
)
|
||||
|
||||
function handleApprove(request) {
|
||||
const message = approveRequest(request)
|
||||
toast(message)
|
||||
}
|
||||
|
||||
function handleReject(request) {
|
||||
const message = rejectRequest(request)
|
||||
toast(message)
|
||||
}
|
||||
|
||||
function handleNavigate(view) {
|
||||
smartEntryOpen.value = false
|
||||
setView(view)
|
||||
}
|
||||
|
||||
function openTravelCreate() {
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
async function resolveSmartEntryConversation(payload = {}) {
|
||||
if (payload.conversation) {
|
||||
return payload.conversation
|
||||
}
|
||||
|
||||
if (!payload.restoreLatestConversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
|
||||
preferRecoverable: true
|
||||
})
|
||||
return latestPayload?.found ? latestPayload.conversation || null : null
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore latest expense conversation for smart entry:', error)
|
||||
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSmartEntry(payload = {}) {
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
smartEntryOpen.value = true
|
||||
|
||||
smartEntryContext.value = {
|
||||
prompt: payload.prompt ?? '',
|
||||
source: payload.source ?? 'workbench',
|
||||
request: payload.request ?? selectedRequest.value,
|
||||
files: Array.isArray(payload.files) ? payload.files : [],
|
||||
conversation
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function closeSmartEntry() {
|
||||
smartEntryOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDraftSaved(payload = {}) {
|
||||
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
|
||||
const status = String(payload.status || payload.claimStatus || '').trim()
|
||||
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
||||
smartEntryOpen.value = false
|
||||
await reloadRequests()
|
||||
if (status === 'submitted') {
|
||||
toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
} else {
|
||||
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
|
||||
}
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
function openRequestDetail(request) {
|
||||
router.push({
|
||||
name: 'app-request-detail',
|
||||
params: { requestId: request.claimId || request.id }
|
||||
})
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
}
|
||||
|
||||
async function handleRequestDeleted() {
|
||||
await reloadRequests()
|
||||
router.push({ name: 'app-requests' })
|
||||
}
|
||||
|
||||
return {
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +84,12 @@ export function fetchAgentAssetDetail(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') {
|
||||
const query = buildQuery({ version })
|
||||
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`)
|
||||
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') {
|
||||
export function fetchAgentAssetSpreadsheetBlob(assetId, disposition = 'inline') {
|
||||
const search = new URLSearchParams()
|
||||
if (version) {
|
||||
search.set('version', String(version).trim())
|
||||
}
|
||||
if (disposition) {
|
||||
search.set('disposition', String(disposition).trim())
|
||||
}
|
||||
@@ -148,14 +144,6 @@ export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
|
||||
const query = new URLSearchParams({
|
||||
base_version: String(baseVersion || '').trim(),
|
||||
target_version: String(targetVersion || '').trim()
|
||||
})
|
||||
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
<div class="spreadsheet-editor-actions">
|
||||
<span class="spreadsheet-mode-pill">
|
||||
{{ selectedSpreadsheetVersionModeLabel }}
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -153,35 +153,34 @@
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="spreadsheet-version-center">
|
||||
<header class="version-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次在线编辑保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="version-center-section version-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="version-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<aside class="spreadsheet-change-center">
|
||||
<header class="change-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="change-center-section change-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="change-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<div class="change-record-head">
|
||||
<div>
|
||||
<strong>{{ item.actor }}</strong>
|
||||
<span>{{ item.time }}</span>
|
||||
</div>
|
||||
<b>{{ item.changeCountLabel }}</b>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.version">关联版本:{{ item.version }}</small>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</small>
|
||||
<div v-if="item.previewChanges.length" class="change-record-preview">
|
||||
<span
|
||||
@@ -197,9 +196,9 @@
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="version-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
<p v-else class="change-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1086,11 +1085,9 @@
|
||||
<span>{{ item.timeLabel }}</span>
|
||||
</header>
|
||||
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
<template v-if="item.version"> · 关联版本:{{ item.version }}</template>
|
||||
<template v-if="item.source_version"> · 来源版本:{{ item.source_version }}</template>
|
||||
</small>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1129,12 +1126,8 @@
|
||||
<span>修改时间</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
|
||||
</article>
|
||||
<article v-if="selectedSpreadsheetChangeRecord.version">
|
||||
<span>关联版本</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.version }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
@@ -1203,127 +1196,6 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="drawer-fade">
|
||||
<div v-if="versionCompareOpen" class="rule-drawer-backdrop" @click.self="closeVersionCompare">
|
||||
<aside class="rule-drawer compare-drawer">
|
||||
<header class="rule-drawer-head">
|
||||
<div>
|
||||
<span>版本治理</span>
|
||||
<h3>版本差异对比</h3>
|
||||
</div>
|
||||
<button type="button" @click="closeVersionCompare">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="compare-toolbar">
|
||||
<label>
|
||||
<span>基准版本</span>
|
||||
<select v-model="compareBaseVersion" @change="loadVersionCompare">
|
||||
<option
|
||||
v-for="item in selectedSkill?.history || []"
|
||||
:key="`base-${item.version}`"
|
||||
:value="item.version"
|
||||
>
|
||||
{{ item.version }} · {{ item.lifecycleMeta.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<i class="mdi mdi-arrow-right"></i>
|
||||
<label>
|
||||
<span>对比版本</span>
|
||||
<select v-model="compareTargetVersion" @change="loadVersionCompare">
|
||||
<option
|
||||
v-for="item in selectedSkill?.history || []"
|
||||
:key="`target-${item.version}`"
|
||||
:value="item.version"
|
||||
>
|
||||
{{ item.version }} · {{ item.lifecycleMeta.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="versionCompareLoading" class="rule-drawer-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在生成版本差异...</span>
|
||||
</div>
|
||||
<div v-else-if="versionCompareError" class="rule-drawer-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ versionCompareError }}</span>
|
||||
</div>
|
||||
<div v-else-if="versionComparePayload" class="compare-content">
|
||||
<section class="compare-summary-grid">
|
||||
<article>
|
||||
<span>新增工作表</span>
|
||||
<strong>{{ versionComparePayload.added_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>删除工作表</span>
|
||||
<strong>{{ versionComparePayload.removed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ versionComparePayload.changed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>变更单元格</span>
|
||||
<strong>{{ versionComparePayload.changed_cell_count }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="compare-panel">
|
||||
<header>
|
||||
<strong>工作表变化</strong>
|
||||
</header>
|
||||
<div v-if="versionCompareSheetRows.length" class="compare-sheet-list">
|
||||
<span
|
||||
v-for="item in versionCompareSheetRows"
|
||||
:key="`${item.sheet_name}-${item.change_type}`"
|
||||
:class="item.meta.tone"
|
||||
>
|
||||
{{ item.sheet_name }} · {{ item.meta.label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else>没有新增或删除工作表。</p>
|
||||
</section>
|
||||
|
||||
<section class="compare-panel compare-cell-panel">
|
||||
<header>
|
||||
<strong>单元格差异</strong>
|
||||
<small>最多展示前 500 条</small>
|
||||
</header>
|
||||
<div v-if="versionCompareCellRows.length" class="compare-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工作表</th>
|
||||
<th>位置</th>
|
||||
<th>类型</th>
|
||||
<th>旧值</th>
|
||||
<th>新值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in versionCompareCellRows"
|
||||
:key="`${item.sheet_name}-${item.cell}`"
|
||||
>
|
||||
<td>{{ item.sheet_name }}</td>
|
||||
<td>{{ item.cell }}</td>
|
||||
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
|
||||
<td>{{ item.before_value ?? '-' }}</td>
|
||||
<td>{{ item.after_value ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else>两个版本内容一致,没有发现单元格级差异。</p>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="assistant-modal">
|
||||
<div class="assistant-overlay">
|
||||
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
|
||||
<div v-if="workbenchVisible" class="assistant-overlay">
|
||||
<section class="assistant-modal">
|
||||
<div class="assistant-header-actions">
|
||||
<button
|
||||
@@ -30,8 +30,7 @@
|
||||
type="button"
|
||||
title="关闭工作台"
|
||||
aria-label="关闭对话工作台"
|
||||
@pointerdown.stop.prevent="requestCloseWorkbench"
|
||||
@click.stop.prevent="requestCloseWorkbench"
|
||||
@click="requestCloseWorkbench"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
@@ -41,8 +40,8 @@
|
||||
<header class="assistant-header">
|
||||
<div class="assistant-header-main">
|
||||
<div>
|
||||
<h2>财务AI工作台</h2>
|
||||
<p>个人工作台、发起报销、智能录入统一走这里,右侧会根据你的意图实时切换状态视图。</p>
|
||||
<h2>财务助手</h2>
|
||||
<p>个人财务中心 · 报销识别、票据核对与制度咨询,右侧会随处理进度展示识别结果与风险提示。</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -79,7 +78,7 @@
|
||||
|
||||
<div class="message-bubble">
|
||||
<header class="message-meta">
|
||||
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
|
||||
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
|
||||
<time>{{ message.time }}</time>
|
||||
</header>
|
||||
<p
|
||||
@@ -89,15 +88,35 @@
|
||||
{{ message.text }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
class="message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
class="message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></motion>
|
||||
|
||||
<motion
|
||||
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
|
||||
class="welcome-quick-actions"
|
||||
>
|
||||
<p class="welcome-quick-actions-title">您可以对我进行以下操作:</p>
|
||||
<div class="welcome-quick-action-grid">
|
||||
<button
|
||||
v-for="action in message.welcomeQuickActions"
|
||||
:key="`${message.id}-${action.label}`"
|
||||
type="button"
|
||||
class="welcome-quick-action-btn"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click="runWelcomeQuickAction(action)"
|
||||
>
|
||||
<i :class="action.icon"></i>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</motion>
|
||||
</motion>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
|
||||
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
|
||||
</div>
|
||||
</motion>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
|
||||
<strong>风险标签</strong>
|
||||
@@ -409,28 +428,120 @@
|
||||
</div>
|
||||
|
||||
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
|
||||
<button
|
||||
v-if="!isKnowledgeSession"
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="上传附件"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="上传附件"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
</button>
|
||||
<div class="composer-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn"
|
||||
:class="{ active: composerDatePickerOpen }"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="选择业务发生时间"
|
||||
:aria-expanded="composerDatePickerOpen"
|
||||
@click.stop="toggleComposerDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="composerDatePickerOpen"
|
||||
class="composer-date-popover"
|
||||
role="dialog"
|
||||
aria-label="业务发生时间"
|
||||
@click.stop
|
||||
>
|
||||
<div class="composer-date-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-mode-btn"
|
||||
:class="{ active: composerDateMode === 'single' }"
|
||||
@click="setComposerDateMode('single')"
|
||||
>
|
||||
当天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-mode-btn"
|
||||
:class="{ active: composerDateMode === 'range' }"
|
||||
@click="setComposerDateMode('range')"
|
||||
>
|
||||
时间段
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
|
||||
<label class="composer-date-field">
|
||||
<span>日期</span>
|
||||
<input v-model="composerSingleDate" type="date" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="composer-date-fields composer-date-fields-range">
|
||||
<label class="composer-date-field">
|
||||
<span>开始</span>
|
||||
<input v-model="composerRangeStartDate" type="date" />
|
||||
</label>
|
||||
<span class="composer-date-range-sep">至</span>
|
||||
<label class="composer-date-field">
|
||||
<span>结束</span>
|
||||
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
|
||||
请确认结束日期不早于开始日期。
|
||||
</p>
|
||||
<div class="composer-date-popover-actions">
|
||||
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-apply-btn"
|
||||
:disabled="!composerCanApplyDateSelection"
|
||||
@click="applyComposerDateSelection"
|
||||
>
|
||||
插入标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-shell">
|
||||
<textarea
|
||||
ref="composerTextareaRef"
|
||||
v-model="composerDraft"
|
||||
rows="1"
|
||||
:placeholder="composerPlaceholder"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@input="handleComposerInput"
|
||||
@keydown.enter.exact.stop
|
||||
@keydown.ctrl.enter.prevent="submitComposer"
|
||||
/>
|
||||
<div class="composer-shell-body">
|
||||
<span
|
||||
v-for="tag in composerBusinessTimeTags"
|
||||
:key="tag.id"
|
||||
class="composer-biz-time-tag"
|
||||
>
|
||||
<i class="mdi mdi-calendar-check"></i>
|
||||
<span class="composer-biz-time-tag-label">{{ tag.label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-biz-time-tag-remove"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="移除业务发生时间"
|
||||
@click="removeComposerBusinessTimeTag(tag.id)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</span>
|
||||
<textarea
|
||||
ref="composerTextareaRef"
|
||||
v-model="composerDraft"
|
||||
rows="1"
|
||||
:placeholder="composerPlaceholder"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@input="handleComposerInput"
|
||||
@keydown.enter.exact.stop
|
||||
@keydown.ctrl.enter.prevent="submitComposer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
activateAgentAsset,
|
||||
compareAgentAssetSpreadsheetVersions,
|
||||
createAgentAssetReview,
|
||||
createAgentAssetVersion,
|
||||
fetchAgentAssetDetail,
|
||||
@@ -969,6 +968,17 @@ function buildRowMetric(asset, typeKey) {
|
||||
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
|
||||
}
|
||||
|
||||
function formatSpreadsheetChangeSummary(summary) {
|
||||
const normalized = normalizeText(summary)
|
||||
return (
|
||||
normalized
|
||||
.replace(/^(ONLYOFFICE\s*)?在线编辑[::]\s*/i, '')
|
||||
.replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '')
|
||||
.replace(/^保存表格[::]\s*/i, '')
|
||||
.trim() || '表格内容已保存。'
|
||||
)
|
||||
}
|
||||
|
||||
function buildListItem(asset) {
|
||||
const typeKey = resolveTypeKey(asset.asset_type)
|
||||
const tabId = resolveTabId(asset, typeKey)
|
||||
@@ -993,6 +1003,9 @@ function buildListItem(asset) {
|
||||
: ''
|
||||
)
|
||||
const isRiskRule = tabId === 'riskRules'
|
||||
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
|
||||
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
|
||||
const ruleDocument = readRuleDocumentMeta(asset)
|
||||
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
|
||||
const listSubtitle = isRiskRule
|
||||
? buildRiskListSubtitle(asset.description)
|
||||
@@ -1003,6 +1016,9 @@ function buildListItem(asset) {
|
||||
tabId,
|
||||
type: typeKey,
|
||||
isPreviewMock: Boolean(asset.isPreviewMock),
|
||||
usesSpreadsheetRule,
|
||||
usesJsonRiskRule,
|
||||
ruleDocument,
|
||||
typeLabel: tabMeta.typeLabel,
|
||||
short: makeShort(asset.name),
|
||||
name: asset.name,
|
||||
@@ -1582,12 +1598,6 @@ export default {
|
||||
const versionTimelineLoading = ref(false)
|
||||
const versionTimelineError = ref('')
|
||||
const versionTimelineItems = ref([])
|
||||
const versionCompareOpen = ref(false)
|
||||
const versionCompareLoading = ref(false)
|
||||
const versionCompareError = ref('')
|
||||
const versionComparePayload = ref(null)
|
||||
const compareBaseVersion = ref('')
|
||||
const compareTargetVersion = ref('')
|
||||
const spreadsheetChangeRecordsByAsset = ref({})
|
||||
const spreadsheetChangeDetailOpen = ref(false)
|
||||
const selectedSpreadsheetChangeRecord = ref(null)
|
||||
@@ -1595,8 +1605,7 @@ export default {
|
||||
let spreadsheetOnlyOfficeLoadTimer = null
|
||||
let spreadsheetOnlyOfficeHadLocalEdits = false
|
||||
let spreadsheetOnlyOfficeSyncSeq = 0
|
||||
let spreadsheetOnlyOfficeVersionPollTimer = null
|
||||
let spreadsheetOnlyOfficeRefreshTimer = null
|
||||
let spreadsheetOnlyOfficeChangePollTimer = null
|
||||
const assetBuckets = ref({
|
||||
financialRules: [],
|
||||
riskRules: [],
|
||||
@@ -1649,8 +1658,7 @@ export default {
|
||||
() =>
|
||||
canEditSelected.value &&
|
||||
selectedSkillUsesSpreadsheet.value &&
|
||||
!detailBusy.value &&
|
||||
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
!detailBusy.value
|
||||
)
|
||||
const canDownloadSpreadsheet = computed(
|
||||
() =>
|
||||
@@ -1661,26 +1669,17 @@ export default {
|
||||
const canEditSpreadsheetInline = computed(
|
||||
() =>
|
||||
selectedSkillUsesSpreadsheet.value &&
|
||||
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion &&
|
||||
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
|
||||
)
|
||||
const selectedDisplayHistory = computed(
|
||||
() =>
|
||||
selectedSkill.value?.history?.find((item) => item.version === selectedSkill.value?.displayVersion) || null
|
||||
)
|
||||
const selectedSpreadsheetFileName = computed(
|
||||
() =>
|
||||
normalizeText(
|
||||
selectedDisplayHistory.value?.spreadsheetMeta?.file_name || selectedSkill.value?.ruleDocument?.file_name
|
||||
) || '未上传规则表'
|
||||
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
|
||||
)
|
||||
const selectedSpreadsheetVersionModeLabel = computed(() => {
|
||||
const selectedSpreadsheetModeLabel = computed(() => {
|
||||
if (selectedSkill.value?.isPreviewMock) {
|
||||
return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑' : 'ONLYOFFICE 预览'
|
||||
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
|
||||
}
|
||||
return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
? '在线可编辑'
|
||||
: '只读预览'
|
||||
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
|
||||
})
|
||||
const selectedVersionTimelineItems = computed(() =>
|
||||
versionTimelineItems.value.map((item) => ({
|
||||
@@ -1709,6 +1708,7 @@ export default {
|
||||
return {
|
||||
...item,
|
||||
time: formatDateTime(item.changed_at),
|
||||
summary: formatSpreadsheetChangeSummary(item.summary),
|
||||
changeCountLabel: item.changed_cell_count
|
||||
? `${item.changed_cell_count} 处改动`
|
||||
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
|
||||
@@ -1736,22 +1736,6 @@ export default {
|
||||
}))
|
||||
: []
|
||||
)
|
||||
const versionCompareCellRows = computed(() =>
|
||||
Array.isArray(versionComparePayload.value?.cell_changes)
|
||||
? versionComparePayload.value.cell_changes.map((item) => ({
|
||||
...item,
|
||||
meta: resolveDiffChangeMeta(item.change_type)
|
||||
}))
|
||||
: []
|
||||
)
|
||||
const versionCompareSheetRows = computed(() =>
|
||||
Array.isArray(versionComparePayload.value?.sheet_changes)
|
||||
? versionComparePayload.value.sheet_changes.map((item) => ({
|
||||
...item,
|
||||
meta: resolveDiffChangeMeta(item.change_type)
|
||||
}))
|
||||
: []
|
||||
)
|
||||
const detailBusy = computed(() => Boolean(actionState.value))
|
||||
const showReviewNote = computed(
|
||||
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
|
||||
@@ -1922,7 +1906,6 @@ export default {
|
||||
watch(
|
||||
() => [
|
||||
selectedSkill.value?.id || '',
|
||||
selectedSkill.value?.displayVersion || '',
|
||||
selectedSkill.value?.loading ? '1' : '0',
|
||||
selectedSkill.value?.usesSpreadsheetRule ? '1' : '0'
|
||||
],
|
||||
@@ -1938,7 +1921,6 @@ export default {
|
||||
)
|
||||
|
||||
watch(activeType, () => {
|
||||
stopSpreadsheetOnlyOfficeDeferredRefresh()
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
selectedSkill.value = null
|
||||
versionSwitchTarget.value = null
|
||||
@@ -2034,8 +2016,7 @@ export default {
|
||||
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
|
||||
spreadsheetOnlyOfficeLoadTimer = null
|
||||
}
|
||||
stopSpreadsheetOnlyOfficeVersionSync()
|
||||
clearSpreadsheetPendingChangeRecord(selectedSkill.value?.id, selectedSkill.value?.displayVersion)
|
||||
stopSpreadsheetOnlyOfficeChangeSync()
|
||||
spreadsheetOnlyOfficeHadLocalEdits = false
|
||||
spreadsheetOnlyOfficeSyncSeq += 1
|
||||
if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
|
||||
@@ -2045,87 +2026,10 @@ export default {
|
||||
spreadsheetOnlyOfficeReady.value = false
|
||||
}
|
||||
|
||||
function appendSpreadsheetChangeRecord(record) {
|
||||
const assetId = normalizeText(record?.assetId)
|
||||
const version = normalizeText(record?.version)
|
||||
if (!assetId || !version) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRecord = {
|
||||
version,
|
||||
operationLabel: normalizeText(record?.operationLabel) || '表格修改',
|
||||
operationActor: normalizeText(record?.operationActor) || resolveActor(),
|
||||
note: normalizeText(record?.note) || '用户修改了表格内容。',
|
||||
time: record?.time || new Date().toISOString(),
|
||||
isWorking: record?.isWorking !== false,
|
||||
isPendingLocalEdit: Boolean(record?.isPendingLocalEdit),
|
||||
disabledReason: normalizeText(record?.disabledReason)
|
||||
}
|
||||
|
||||
const current = spreadsheetChangeRecordsByAsset.value[assetId] || []
|
||||
const deduped = current.filter(
|
||||
(item) =>
|
||||
!(
|
||||
item.version === nextRecord.version &&
|
||||
item.operationLabel === nextRecord.operationLabel &&
|
||||
item.note === nextRecord.note
|
||||
)
|
||||
)
|
||||
spreadsheetChangeRecordsByAsset.value = {
|
||||
...spreadsheetChangeRecordsByAsset.value,
|
||||
[assetId]: [nextRecord, ...deduped].slice(0, 30)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSpreadsheetPendingChangeRecord(assetId, version) {
|
||||
const normalizedAssetId = normalizeText(assetId)
|
||||
const normalizedVersion = normalizeText(version)
|
||||
if (!normalizedAssetId) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = spreadsheetChangeRecordsByAsset.value[normalizedAssetId] || []
|
||||
spreadsheetChangeRecordsByAsset.value = {
|
||||
...spreadsheetChangeRecordsByAsset.value,
|
||||
[normalizedAssetId]: current.filter(
|
||||
(item) => !(item.isPendingLocalEdit && (!normalizedVersion || item.version === normalizedVersion))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function markSpreadsheetPendingChange(assetId, version) {
|
||||
const normalizedAssetId = normalizeText(assetId)
|
||||
const normalizedVersion = normalizeText(version)
|
||||
if (!normalizedAssetId || !normalizedVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
|
||||
appendSpreadsheetChangeRecord({
|
||||
assetId: normalizedAssetId,
|
||||
version: normalizedVersion,
|
||||
operationLabel: '编辑中',
|
||||
operationActor: resolveActor(),
|
||||
note: '检测到未保存的表格改动,保存后会生成新版本并可查看差异。',
|
||||
time: new Date().toISOString(),
|
||||
isWorking: true,
|
||||
isPendingLocalEdit: true,
|
||||
disabledReason: '当前是本地未保存修改,保存后才会生成可对比的版本。'
|
||||
})
|
||||
}
|
||||
|
||||
function stopSpreadsheetOnlyOfficeVersionSync() {
|
||||
if (spreadsheetOnlyOfficeVersionPollTimer) {
|
||||
window.clearTimeout(spreadsheetOnlyOfficeVersionPollTimer)
|
||||
spreadsheetOnlyOfficeVersionPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopSpreadsheetOnlyOfficeDeferredRefresh() {
|
||||
if (spreadsheetOnlyOfficeRefreshTimer) {
|
||||
window.clearTimeout(spreadsheetOnlyOfficeRefreshTimer)
|
||||
spreadsheetOnlyOfficeRefreshTimer = null
|
||||
function stopSpreadsheetOnlyOfficeChangeSync() {
|
||||
if (spreadsheetOnlyOfficeChangePollTimer) {
|
||||
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
|
||||
spreadsheetOnlyOfficeChangePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2164,7 +2068,6 @@ export default {
|
||||
latest.id,
|
||||
latest.changed_at,
|
||||
latest.actor,
|
||||
latest.version,
|
||||
latest.summary,
|
||||
latest.changed_sheet_count,
|
||||
latest.changed_cell_count,
|
||||
@@ -2193,36 +2096,14 @@ export default {
|
||||
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
|
||||
}
|
||||
|
||||
function scheduleSpreadsheetEditorRefreshAfterSave(assetId, savedVersion) {
|
||||
function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) {
|
||||
const normalizedAssetId = normalizeText(assetId)
|
||||
const normalizedSavedVersion = normalizeText(savedVersion)
|
||||
if (!normalizedAssetId || !normalizedSavedVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
stopSpreadsheetOnlyOfficeDeferredRefresh()
|
||||
spreadsheetOnlyOfficeRefreshTimer = window.setTimeout(async () => {
|
||||
spreadsheetOnlyOfficeRefreshTimer = null
|
||||
if (
|
||||
selectedSkill.value?.id !== normalizedAssetId ||
|
||||
selectedSkill.value?.displayVersion === normalizedSavedVersion
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadSelectedAssetDetail(normalizedAssetId)
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
function scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version, attempt = 0) {
|
||||
const normalizedAssetId = normalizeText(assetId)
|
||||
const normalizedVersion = normalizeText(version)
|
||||
if (!normalizedAssetId || !normalizedVersion) {
|
||||
if (!normalizedAssetId) {
|
||||
return
|
||||
}
|
||||
|
||||
const syncSeq = ++spreadsheetOnlyOfficeSyncSeq
|
||||
stopSpreadsheetOnlyOfficeVersionSync()
|
||||
stopSpreadsheetOnlyOfficeChangeSync()
|
||||
const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
|
||||
|
||||
const runSync = async () => {
|
||||
@@ -2231,31 +2112,13 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await fetchAgentAssetDetail(normalizedAssetId)
|
||||
const nextWorkingVersion = normalizeText(detail?.working_version || detail?.current_version)
|
||||
if (nextWorkingVersion && nextWorkingVersion !== normalizedVersion) {
|
||||
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
|
||||
await refreshCurrentAssets()
|
||||
await refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestChangeKey)
|
||||
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
|
||||
return
|
||||
}
|
||||
// ONLYOFFICE 的保存回调刚结束时立即销毁并重挂编辑器,偶发会让新文档会话
|
||||
// 还没完全就绪就被再次打开,表现为“加载超时”。先刷新右侧修改记录,再留
|
||||
// 一个很短的缓冲窗口后切换到新工作版本,用户无需退出重进。
|
||||
scheduleSpreadsheetEditorRefreshAfterSave(normalizedAssetId, nextWorkingVersion)
|
||||
stopSpreadsheetOnlyOfficeVersionSync()
|
||||
return
|
||||
}
|
||||
|
||||
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
|
||||
normalizedAssetId,
|
||||
previousLatestChangeKey
|
||||
)
|
||||
if (changeRecordRefreshed) {
|
||||
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
|
||||
await refreshCurrentAssets()
|
||||
stopSpreadsheetOnlyOfficeVersionSync()
|
||||
stopSpreadsheetOnlyOfficeChangeSync()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -2268,22 +2131,21 @@ export default {
|
||||
if (attempt >= 29) {
|
||||
return
|
||||
}
|
||||
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
|
||||
scheduleSpreadsheetOnlyOfficeVersionSync(normalizedAssetId, normalizedVersion, attempt + 1)
|
||||
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
|
||||
scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
|
||||
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
|
||||
runSync().catch(() => {})
|
||||
}, attempt === 0 ? 800 : 2000)
|
||||
}
|
||||
|
||||
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version) {
|
||||
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) {
|
||||
return (
|
||||
mountSeq !== spreadsheetOnlyOfficeMountSeq ||
|
||||
!selectedSkillUsesSpreadsheet.value ||
|
||||
selectedSkill.value?.id !== assetId ||
|
||||
selectedSkill.value?.displayVersion !== version ||
|
||||
selectedSkill.value?.loading
|
||||
)
|
||||
}
|
||||
@@ -2296,7 +2158,6 @@ export default {
|
||||
|
||||
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
|
||||
const assetId = selectedSkill.value.id
|
||||
const version = selectedSkill.value.displayVersion
|
||||
const editable = canEditSpreadsheetInline.value
|
||||
|
||||
spreadsheetOnlyOfficeLoading.value = true
|
||||
@@ -2305,25 +2166,25 @@ export default {
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
|
||||
try {
|
||||
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version)
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId)
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadOnlyOfficeApi(payload.documentServerUrl)
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
if (!window.DocsAPI?.DocEditor) {
|
||||
throw new Error('ONLYOFFICE 编辑器未正确加载。')
|
||||
throw new Error('表格编辑器未正确加载。')
|
||||
}
|
||||
|
||||
// Host id must be unique for every mount. ONLYOFFICE mutates its host DOM
|
||||
// during lifecycle teardown; reusing the same element can leave the next
|
||||
// DocEditor instance with a dead container even though config loading succeeds.
|
||||
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${version}-${mountSeq}`
|
||||
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}`
|
||||
await nextTick()
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2334,7 +2195,7 @@ export default {
|
||||
})
|
||||
const upstreamEvents = config.events || {}
|
||||
spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
if (retryAttempt < 1) {
|
||||
@@ -2345,14 +2206,14 @@ export default {
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
spreadsheetOnlyOfficeError.value = 'ONLYOFFICE 加载超时,请重新切换版本后重试。'
|
||||
spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。'
|
||||
spreadsheetOnlyOfficeLoading.value = false
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
}, 15000)
|
||||
config.events = {
|
||||
...upstreamEvents,
|
||||
onAppReady(event) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
if (spreadsheetOnlyOfficeLoadTimer) {
|
||||
@@ -2364,7 +2225,7 @@ export default {
|
||||
upstreamEvents.onAppReady?.(event)
|
||||
},
|
||||
onError(event) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
if (spreadsheetOnlyOfficeLoadTimer) {
|
||||
@@ -2374,8 +2235,8 @@ export default {
|
||||
const errorCode = event?.data?.errorCode
|
||||
const errorDescription = event?.data?.errorDescription
|
||||
spreadsheetOnlyOfficeError.value = errorDescription
|
||||
? `ONLYOFFICE 加载失败:${errorDescription}`
|
||||
: `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}`
|
||||
? `表格加载失败:${errorDescription}`
|
||||
: `表格加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}`
|
||||
spreadsheetOnlyOfficeLoading.value = false
|
||||
upstreamEvents.onError?.(event)
|
||||
},
|
||||
@@ -2383,17 +2244,16 @@ export default {
|
||||
const hasChanges = Boolean(event?.data)
|
||||
if (hasChanges) {
|
||||
spreadsheetOnlyOfficeHadLocalEdits = true
|
||||
markSpreadsheetPendingChange(assetId, version)
|
||||
if (!spreadsheetOnlyOfficeVersionPollTimer) {
|
||||
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
|
||||
if (!spreadsheetOnlyOfficeChangePollTimer) {
|
||||
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
|
||||
}
|
||||
} else if (
|
||||
spreadsheetOnlyOfficeHadLocalEdits &&
|
||||
editable &&
|
||||
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)
|
||||
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)
|
||||
) {
|
||||
spreadsheetOnlyOfficeHadLocalEdits = false
|
||||
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
|
||||
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
|
||||
}
|
||||
upstreamEvents.onDocumentStateChange?.(event)
|
||||
}
|
||||
@@ -2402,11 +2262,11 @@ export default {
|
||||
spreadsheetOnlyOfficeHostId.value,
|
||||
config
|
||||
)
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
|
||||
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
|
||||
return
|
||||
}
|
||||
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
|
||||
@@ -2431,7 +2291,6 @@ export default {
|
||||
try {
|
||||
const blob = await fetchAgentAssetSpreadsheetBlob(
|
||||
selectedSkill.value.id,
|
||||
selectedSkill.value.displayVersion,
|
||||
'attachment'
|
||||
)
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
@@ -2462,7 +2321,7 @@ export default {
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
|
||||
toast(`已导入 ${file.name} 的表格内容,并生成新版本。`)
|
||||
toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || '规则表内容导入失败,请稍后重试。')
|
||||
} finally {
|
||||
@@ -2560,7 +2419,7 @@ export default {
|
||||
const detail = await fetchAgentAssetDetail(assetId)
|
||||
selectedSkill.value = buildDetailViewModel(detail, runs.value)
|
||||
if (selectedSkill.value?.type === 'rules') {
|
||||
if (!selectedSkill.value.usesJsonRiskRule) {
|
||||
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
|
||||
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
|
||||
}
|
||||
if (selectedSkill.value.usesSpreadsheetRule) {
|
||||
@@ -2677,7 +2536,6 @@ export default {
|
||||
}
|
||||
|
||||
function openAssetDetail(asset) {
|
||||
stopSpreadsheetOnlyOfficeDeferredRefresh()
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
spreadsheetOnlyOfficeError.value = ''
|
||||
spreadsheetOnlyOfficeLoading.value = false
|
||||
@@ -2688,17 +2546,18 @@ export default {
|
||||
versionSwitchTarget.value = null
|
||||
return
|
||||
}
|
||||
const opensSpreadsheetRule = Boolean(asset?.usesSpreadsheetRule)
|
||||
selectedSkill.value = {
|
||||
...asset,
|
||||
configJson: {},
|
||||
isPreviewMock: false,
|
||||
usesSpreadsheetRule: false,
|
||||
usesJsonRiskRule: false,
|
||||
usesSpreadsheetRule: opensSpreadsheetRule,
|
||||
usesJsonRiskRule: Boolean(asset?.usesJsonRiskRule),
|
||||
riskRuleJsonText: '{}',
|
||||
riskRuleSummary: null,
|
||||
riskRuleDescription: '',
|
||||
riskRuleSourceRef: '',
|
||||
ruleDocument: null,
|
||||
ruleDocument: asset?.ruleDocument || null,
|
||||
scenarioList: [],
|
||||
fields: [],
|
||||
promptSections: [],
|
||||
@@ -2714,16 +2573,18 @@ export default {
|
||||
runtimeKind: 'policy_rule_draft',
|
||||
displayVersion: asset.version,
|
||||
displayVersionChangeNote: '无版本说明',
|
||||
loading: true,
|
||||
reviewStatusLabel: '加载中',
|
||||
loading: !opensSpreadsheetRule,
|
||||
reviewStatusLabel: opensSpreadsheetRule ? '' : '加载中',
|
||||
reviewStatusTone: 'draft'
|
||||
}
|
||||
versionSwitchTarget.value = null
|
||||
if (opensSpreadsheetRule) {
|
||||
loadSpreadsheetChangeRecords(asset.id).catch(() => {})
|
||||
}
|
||||
loadSelectedAssetDetail(asset.id).catch(() => {})
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
stopSpreadsheetOnlyOfficeDeferredRefresh()
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
spreadsheetOnlyOfficeError.value = ''
|
||||
spreadsheetOnlyOfficeLoading.value = false
|
||||
@@ -2732,9 +2593,7 @@ export default {
|
||||
detailLoading.value = false
|
||||
versionSwitchTarget.value = null
|
||||
versionTimelineOpen.value = false
|
||||
versionCompareOpen.value = false
|
||||
versionTimelineItems.value = []
|
||||
versionComparePayload.value = null
|
||||
}
|
||||
|
||||
function openVersionSwitch(version) {
|
||||
@@ -3062,66 +2921,6 @@ export default {
|
||||
versionTimelineOpen.value = false
|
||||
}
|
||||
|
||||
async function openVersionCompare(options = {}) {
|
||||
if (!selectedSkill.value?.id) {
|
||||
return
|
||||
}
|
||||
const defaultBase =
|
||||
options.baseVersion || selectedSkill.value.publishedVersion || selectedSkill.value.workingVersion || ''
|
||||
let defaultTarget =
|
||||
options.targetVersion || selectedSkill.value.workingVersion || selectedSkill.value.publishedVersion || ''
|
||||
if (!options.targetVersion && defaultBase === defaultTarget) {
|
||||
defaultTarget =
|
||||
selectedSkill.value.history.find((item) => item.version !== defaultBase)?.version || defaultTarget
|
||||
}
|
||||
compareBaseVersion.value = defaultBase
|
||||
compareTargetVersion.value = defaultTarget
|
||||
versionCompareOpen.value = true
|
||||
await loadVersionCompare()
|
||||
}
|
||||
|
||||
function openSpreadsheetChangeRecord(item) {
|
||||
if (selectedSkill.value?.isPreviewMock) {
|
||||
toast('预览数据暂不支持真实的线上差异对比。')
|
||||
return
|
||||
}
|
||||
const publishedVersion = normalizeText(selectedSkill.value?.publishedVersion)
|
||||
if (!selectedSkill.value?.id || !publishedVersion || publishedVersion === '-') {
|
||||
toast('当前还没有线上版本,暂时无法查看与线上差异。')
|
||||
return
|
||||
}
|
||||
|
||||
openVersionCompare({
|
||||
baseVersion: publishedVersion,
|
||||
targetVersion: item.version
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function closeVersionCompare() {
|
||||
versionCompareOpen.value = false
|
||||
}
|
||||
|
||||
async function loadVersionCompare() {
|
||||
if (!selectedSkill.value?.id || !compareBaseVersion.value || !compareTargetVersion.value) {
|
||||
return
|
||||
}
|
||||
|
||||
versionCompareLoading.value = true
|
||||
versionCompareError.value = ''
|
||||
try {
|
||||
versionComparePayload.value = await compareAgentAssetSpreadsheetVersions(
|
||||
selectedSkill.value.id,
|
||||
compareBaseVersion.value,
|
||||
compareTargetVersion.value
|
||||
)
|
||||
} catch (error) {
|
||||
versionComparePayload.value = null
|
||||
versionCompareError.value = error?.message || '版本差异对比失败,请稍后重试。'
|
||||
} finally {
|
||||
versionCompareLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
loadAssets({ force: true }).catch(() => {})
|
||||
@@ -3129,7 +2928,6 @@ export default {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopSpreadsheetOnlyOfficeDeferredRefresh()
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
@@ -3186,7 +2984,7 @@ export default {
|
||||
selectedSkillUsesSpreadsheet,
|
||||
selectedSkillUsesJsonRisk,
|
||||
selectedSpreadsheetFileName,
|
||||
selectedSpreadsheetVersionModeLabel,
|
||||
selectedSpreadsheetModeLabel,
|
||||
selectedVersionTimelineItems,
|
||||
selectedSpreadsheetChangeRecords,
|
||||
detailBusy,
|
||||
@@ -3205,18 +3003,10 @@ export default {
|
||||
versionTimelineOpen,
|
||||
versionTimelineLoading,
|
||||
versionTimelineError,
|
||||
versionCompareOpen,
|
||||
versionCompareLoading,
|
||||
versionCompareError,
|
||||
versionComparePayload,
|
||||
versionCompareCellRows,
|
||||
versionCompareSheetRows,
|
||||
spreadsheetChangeDetailOpen,
|
||||
selectedSpreadsheetChangeRecord,
|
||||
selectedSpreadsheetChangeSheetRows,
|
||||
selectedSpreadsheetChangeCellRows,
|
||||
compareBaseVersion,
|
||||
compareTargetVersion,
|
||||
openAssetDetail,
|
||||
closeDetail,
|
||||
resetFilters,
|
||||
@@ -3243,12 +3033,8 @@ export default {
|
||||
restoreSelectedVersion,
|
||||
openVersionTimeline,
|
||||
closeVersionTimeline,
|
||||
openSpreadsheetChangeRecord,
|
||||
openSpreadsheetChangeDetail,
|
||||
closeSpreadsheetChangeDetail,
|
||||
openVersionCompare,
|
||||
closeVersionCompare,
|
||||
loadVersionCompare,
|
||||
loadAssets
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user