Files
X-Financial/web/src/components/shared/OperationFeedbackInlineCard.vue

475 lines
11 KiB
Vue
Raw Normal View History

<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>