Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
170 lines
6.3 KiB
Vue
170 lines
6.3 KiB
Vue
<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>
|