Files
X-Financial/web/src/components/shared/OperationFeedbackInlineCard.vue
caoxiaozhu 7989f3a159 feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
2026-05-30 15:46:51 +08:00

475 lines
11 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>
<section
ref="feedbackRootRef"
class="operation-feedback-inline"
:class="{ 'is-submitted': submitted }"
:aria-labelledby="feedbackTitleId"
>
<header class="operation-feedback-inline-head">
<strong :id="feedbackTitleId">这次处理符合预期吗</strong>
<button
v-if="!submitted"
type="button"
class="operation-feedback-link"
:disabled="busy"
@click="emit('dismiss')"
>
稍后
</button>
</header>
<div
class="operation-feedback-stars"
role="radiogroup"
:aria-labelledby="feedbackTitleId"
:aria-describedby="feedbackDescriptionId"
@mouseleave="hoverRating = 0"
>
<button
v-for="option in ratingOptions"
:key="option.value"
type="button"
class="operation-feedback-star"
:class="{ active: option.value === effectiveRating, preview: option.value <= displayRating }"
:disabled="busy || submitted"
:aria-checked="effectiveRating === option.value ? 'true' : 'false'"
:aria-label="`${option.value}星,${option.label}`"
:data-score="option.value"
role="radio"
:tabindex="resolveRatingTabIndex(option.value)"
@click="selectRating(option.value)"
@mouseenter="hoverRating = option.value"
@focus="hoverRating = option.value"
@blur="hoverRating = 0"
@keydown="handleRatingKeydown($event, option.value)"
>
<i
aria-hidden="true"
:class="option.value <= displayRating ? 'mdi mdi-star' : 'mdi mdi-star-outline'"
></i>
</button>
</div>
<p :id="feedbackDescriptionId" class="operation-feedback-status" aria-live="polite">
{{ selectedFeedbackText }}
</p>
<p v-if="submitted" class="operation-feedback-thanks" aria-live="polite">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
<span>感谢您的反馈谢谢</span>
</p>
<Transition name="feedback-reason-slide">
<div v-if="showReasonInput" class="operation-feedback-low-rating">
<label class="operation-feedback-reason">
<span>哪里不符合预期</span>
<textarea
v-model="reason"
maxlength="500"
rows="2"
:disabled="busy"
placeholder="例如:意图识别不准、信息提取遗漏..."
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<button
type="button"
class="operation-feedback-primary"
:disabled="busy || !rating"
@click="submit"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
<span>{{ busy ? '提交中...' : '提交' }}</span>
</button>
</div>
</Transition>
<p v-if="errorMessage" class="operation-feedback-error">{{ errorMessage }}</p>
</section>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
busy: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
resetKey: { type: String, default: '' },
submitted: { type: Boolean, default: false },
submittedRating: { type: Number, default: 0 }
})
const emit = defineEmits(['dismiss', 'submit'])
const feedbackIdSuffix = Math.random().toString(36).slice(2, 10)
const feedbackTitleId = `operation-feedback-title-${feedbackIdSuffix}`
const feedbackDescriptionId = `operation-feedback-note-${feedbackIdSuffix}`
const ratingOptions = [
{ value: 1, label: '很差' },
{ value: 2, label: '不满意' },
{ value: 3, label: '一般' },
{ value: 4, label: '满意' },
{ value: 5, label: '很好' }
]
const rating = ref(0)
const hoverRating = ref(0)
const reason = ref('')
const feedbackRootRef = ref(null)
const normalizedSubmittedRating = computed(() => {
const score = Number(props.submittedRating || 0)
return Number.isInteger(score) && score >= 1 && score <= 5 ? score : 0
})
const effectiveRating = computed(() => (props.submitted ? normalizedSubmittedRating.value || rating.value : rating.value))
const displayRating = computed(() => (props.submitted ? effectiveRating.value : hoverRating.value || rating.value))
const selectedOption = computed(() => ratingOptions.find((option) => option.value === effectiveRating.value) || null)
const selectedFeedbackText = computed(() => (
props.submitted
? '感谢您的反馈。谢谢'
: selectedOption.value
? rating.value <= 3
? `已选择 ${selectedOption.value.value} 星,可补充原因后提交。`
: `已选择 ${selectedOption.value.value} 星,按 Enter 确认。`
: '请选择一个评分。'
))
const showReasonInput = computed(() => !props.submitted && rating.value > 0 && rating.value <= 3)
function focusRatingButton(score) {
nextTick(() => {
feedbackRootRef.value
?.querySelector(`[data-score="${score}"]`)
?.focus()
})
}
function selectRating(score, options = {}) {
if (props.busy || props.submitted) {
return
}
const shouldSubmitHighRating = options.submitHighRating !== false
rating.value = score
if (score > 3 && shouldSubmitHighRating) {
reason.value = ''
emit('submit', {
rating: score,
reason: ''
})
}
}
function resolveRatingTabIndex(score) {
if (props.submitted) {
return -1
}
return rating.value
? rating.value === score ? 0 : -1
: score === 1 ? 0 : -1
}
function handleRatingKeydown(event, score) {
const key = event.key
const currentIndex = ratingOptions.findIndex((option) => option.value === score)
if (currentIndex < 0) {
return
}
const lastIndex = ratingOptions.length - 1
let nextIndex = currentIndex
if (['ArrowRight', 'ArrowDown'].includes(key)) {
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
} else if (['ArrowLeft', 'ArrowUp'].includes(key)) {
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
} else if (key === 'Home') {
nextIndex = 0
} else if (key === 'End') {
nextIndex = lastIndex
} else {
return
}
event.preventDefault()
const nextScore = ratingOptions[nextIndex].value
selectRating(nextScore, { submitHighRating: false })
focusRatingButton(nextScore)
}
function resetForm() {
rating.value = 0
hoverRating.value = 0
reason.value = ''
}
function submit() {
if (props.submitted) {
return
}
emit('submit', {
rating: rating.value,
reason: reason.value
})
}
watch(
() => props.resetKey,
() => resetForm()
)
watch(
() => props.submittedRating,
(nextRating) => {
const score = Number(nextRating || 0)
if (props.submitted && Number.isInteger(score) && score >= 1 && score <= 5) {
rating.value = score
hoverRating.value = 0
}
}
)
</script>
<style scoped>
.operation-feedback-inline {
width: fit-content;
max-width: min(100%, 360px);
display: inline-grid;
grid-template-columns: minmax(0, auto);
gap: 8px;
padding: 9px 11px;
border: 1px solid #d8e2ee;
border-radius: 14px;
background: #ffffff;
color: #24324a;
box-shadow: 0 8px 18px rgb(148 163 184 / 12%);
}
.operation-feedback-inline.is-submitted {
border-color: #d0d5dd;
background: #f8fafc;
color: #475467;
box-shadow: none;
}
.operation-feedback-inline-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.operation-feedback-inline strong {
color: #172033;
font-size: 12px;
font-weight: 760;
line-height: 1.35;
}
.operation-feedback-link {
flex: 0 0 auto;
height: 22px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #475467;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.operation-feedback-link:hover,
.operation-feedback-link:focus-visible {
border-color: #c8d5e6;
background: #f5f8fc;
color: #1d4ed8;
outline: none;
}
.operation-feedback-stars {
display: flex;
align-items: center;
gap: 3px;
}
.operation-feedback-star {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #98a2b3;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
}
.operation-feedback-star:hover,
.operation-feedback-star:focus-visible {
border-color: #d69a2d;
background: #fffaf0;
color: #b7791f;
outline: none;
}
.operation-feedback-star.preview {
color: #b7791f;
}
.operation-feedback-star.active {
border-color: #c78315;
background: #fff7e6;
color: #8a4f00;
}
.operation-feedback-star:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.preview {
color: #667085;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.active {
border-color: #d0d5dd;
background: #eef2f6;
color: #475467;
}
.operation-feedback-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.operation-feedback-low-rating {
display: grid;
gap: 7px;
min-width: min(320px, calc(100vw - 96px));
}
.operation-feedback-reason {
display: grid;
gap: 6px;
}
.operation-feedback-reason span {
color: #344054;
font-size: 11px;
font-weight: 760;
}
.operation-feedback-reason textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 54px;
padding: 7px 9px;
border: 1px solid #d0d5dd;
border-radius: 4px;
background: #ffffff;
color: #172033;
font: inherit;
font-size: 12px;
line-height: 1.5;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.operation-feedback-reason textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
.operation-feedback-reason small {
justify-self: end;
color: #98a2b3;
font-size: 11px;
}
.operation-feedback-error {
margin: 0;
color: #b42318;
font-size: 12px;
}
.operation-feedback-thanks {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0;
color: #475467;
font-size: 12px;
font-weight: 760;
line-height: 1.45;
}
.operation-feedback-thanks i {
color: #667085;
font-size: 14px;
}
.operation-feedback-primary {
justify-self: end;
min-width: 58px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #2563eb;
border-radius: 4px;
font-size: 11px;
font-weight: 760;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, opacity 0.18s ease;
}
.operation-feedback-primary {
background: #2563eb;
color: #ffffff;
}
.operation-feedback-primary:hover:not(:disabled),
.operation-feedback-primary:focus-visible {
border-color: #1d4ed8;
background: #1d4ed8;
outline: none;
}
.operation-feedback-link:disabled,
.operation-feedback-primary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.feedback-reason-slide-enter-active,
.feedback-reason-slide-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.feedback-reason-slide-enter-from,
.feedback-reason-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (max-width: 540px) {
.operation-feedback-inline {
max-width: 100%;
}
.operation-feedback-low-rating {
min-width: min(280px, calc(100vw - 80px));
}
}
</style>