Files
X-Financial/web/src/components/dashboard/CfoValueActionDialog.vue
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

170 lines
6.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog
:model-value="open"
class="cfo-value-action-dialog"
width="min(92vw, 560px)"
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="actionMeta.label"
@update:model-value="$emit('update:open', $event)"
>
<form class="value-action-form" @submit.prevent="submit">
<div class="value-action-context">
<span>{{ contextLabel }}</span>
<strong>{{ targetTitle }}</strong>
<small>当前版本 v{{ Number(target?.version || 0) }}</small>
</div>
<label v-if="action === 'reverse'" class="value-action-field">
<span>冲回金额 <em aria-hidden="true">*</em></span>
<input
v-model="form.reversalAmount"
type="number"
min="0.01"
step="0.01"
inputmode="decimal"
autocomplete="off"
/>
</label>
<label class="value-action-field">
<span>操作说明 <em aria-hidden="true">*</em></span>
<textarea
v-model.trim="form.comment"
rows="4"
maxlength="1000"
:placeholder="commentPlaceholder"
></textarea>
<small>至少 2 个字符说明将写入不可变事件记录</small>
</label>
<p v-if="validationError || error" class="value-action-error" role="alert">
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
{{ validationError || error?.message || '操作失败,请刷新后重试。' }}
</p>
</form>
<template #footer>
<div class="value-action-footer">
<button type="button" class="btn ghost" :disabled="submitting" @click="$emit('update:open', false)">
取消
</button>
<button
type="button"
class="btn"
:class="actionMeta.tone === 'danger' ? 'danger' : 'primary'"
:disabled="submitting"
@click="submit"
>
<i v-if="submitting" class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
{{ submitting ? '正在写入证据链' : actionMeta.label }}
</button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { createRequestId, resolveActionMeta } from '../../views/scripts/cfoValueDashboardModel.js'
const props = defineProps({
open: { type: Boolean, default: false },
action: { type: String, default: '' },
target: { type: Object, default: () => null },
opportunity: { type: Object, default: () => null },
submitting: { type: Boolean, default: false },
error: { type: Object, default: () => null }
})
const emit = defineEmits(['update:open', 'submit'])
const form = reactive(createForm())
const validationError = ref('')
const actionMeta = computed(() => {
const meta = resolveActionMeta(props.action)
if (props.target?.realizationType || props.target?.realizationKey) {
return { ...meta, kind: 'realization' }
}
return meta
})
const targetTitle = computed(() => (
props.opportunity?.title
|| props.target?.title
|| props.opportunity?.claimNoSnapshot
|| props.target?.id
|| '节省机会'
))
const contextLabel = computed(() => (
actionMeta.value.kind === 'realization' ? '实际结果' : '节省机会'
))
const commentPlaceholder = computed(() => {
if (props.action === 'confirm') return '请说明核验依据或外部凭证来源'
if (props.action === 'reverse') return '请说明冲回原因及对应事实'
if (props.action === 'reject') return '请说明拒绝原因'
return '请说明本次操作依据'
})
watch(
() => [props.open, props.action, props.target?.id],
([open]) => {
if (!open) return
Object.assign(form, createForm(props.target, props.opportunity))
validationError.value = ''
},
{ immediate: true }
)
function submit() {
validationError.value = validate()
if (validationError.value || props.submitting) return
emit('submit', {
kind: actionMeta.value.kind,
action: props.action,
requestId: form.requestId,
opportunityId: props.opportunity?.id || props.target?.id,
realizationId: actionMeta.value.kind === 'realization' ? props.target?.id : undefined,
expectedVersion: Number(props.target?.version || 0),
comment: form.comment,
...(props.action === 'reverse' ? { reversalAmount: form.reversalAmount } : {})
})
}
function validate() {
if (String(form.comment || '').trim().length < 2) return '请填写至少 2 个字符的操作说明。'
if (Number(props.target?.version || 0) < 1) return '当前记录缺少有效版本,请刷新详情后重试。'
if (props.action === 'reverse' && Number(form.reversalAmount) <= 0) return '冲回金额必须大于 0。'
return ''
}
function createForm() {
return {
requestId: createRequestId('cfo-value-action'),
comment: '',
reversalAmount: ''
}
}
</script>
<style scoped>
.value-action-form { display: grid; gap: 18px; }
.value-action-context { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.value-action-context span, .value-action-context small { color: var(--muted); font-size: 12px; }
.value-action-context strong { color: var(--ink); font-size: 14px; }
.value-action-field { display: grid; gap: 7px; color: var(--text); font-size: 13px; font-weight: 700; }
.value-action-field em { color: var(--danger); font-style: normal; }
.value-action-field input, .value-action-field textarea { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); color: var(--ink); }
.value-action-field textarea { resize: vertical; line-height: 1.55; }
.value-action-field small { color: var(--muted); font-size: 12px; font-weight: 500; line-height: 1.55; }
.value-action-error { display: flex; align-items: flex-start; gap: 8px; color: var(--danger-active); font-size: 13px; line-height: 1.5; }
.value-action-footer { display: flex; justify-content: flex-end; gap: 10px; }
.value-action-footer .btn { min-width: 112px; min-height: 44px; }
.btn:disabled { cursor: not-allowed; opacity: .48; transform: none; box-shadow: none; }
@media (max-width: 560px) {
.value-action-footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.value-action-footer .btn { width: 100%; min-width: 0; }
}
</style>