refactor(life): extract rating control and reorganize post actions

Extract 5-star rating UI into a dedicated LifeRatingControl component
Move moderation status and retry button into the engagement actions bar
This commit is contained in:
2026-05-03 19:06:02 +08:00
parent 105274eec8
commit d80c9325cd
3 changed files with 216 additions and 106 deletions

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconStar, iconStarOutline } from '../icons';
const props = withDefaults(
defineProps<{
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
disabled?: boolean;
busy?: boolean;
}>(),
{
disabled: false,
busy: false
}
);
const emit = defineEmits<{
rate: [rating: number];
}>();
const { locale, t } = useI18n();
const ratings = [1, 2, 3, 4, 5] as const;
const formattedAverage = computed(() => {
if (props.ratingAverage === null || props.ratingCount === 0) {
return '-';
}
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(props.ratingAverage);
});
const summaryLabel = computed(() => {
if (props.ratingAverage === null || props.ratingCount === 0) {
return t('pages.life.noRatings');
}
return t('pages.life.ratingAverage', {
average: formattedAverage.value,
count: props.ratingCount
});
});
function buttonLabel(rating: number) {
return props.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
}
</script>
<template>
<div class="life-rating-control" role="group" :aria-busy="busy" :aria-label="t('pages.life.rating')">
<div class="life-rating-control__stars" role="group" :aria-label="t('pages.life.rating')">
<button
v-for="rating in ratings"
:key="rating"
class="life-rating-control__star"
:class="{ 'is-active': myRating !== null && rating <= myRating }"
type="button"
:aria-label="buttonLabel(rating)"
:aria-pressed="myRating === rating"
:disabled="disabled || busy"
@click="emit('rate', rating)"
>
<Icon :icon="myRating !== null && rating <= myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
</button>
</div>
<span class="life-rating-control__summary" :aria-label="summaryLabel">{{ formattedAverage }}</span>
</div>
</template>

View File

@@ -2032,59 +2032,6 @@ button:disabled,
white-space: pre-wrap;
}
.life-rating {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.life-rating__stars {
display: inline-flex;
align-items: center;
gap: 4px;
}
.life-rating__star {
width: 44px;
min-width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: color-mix(in srgb, var(--warning) 80%, var(--ink-soft));
cursor: pointer;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease;
}
.life-rating__star:hover,
.life-rating__star.is-active {
border-color: color-mix(in srgb, var(--warning) 72%, var(--line));
background: color-mix(in srgb, var(--warning) 16%, var(--surface-soft));
color: color-mix(in srgb, var(--warning) 84%, var(--ink));
}
.life-rating__star:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.life-rating__star .ui-icon {
width: 22px;
height: 22px;
}
.life-rating__summary {
color: var(--muted);
font-size: 14px;
font-weight: 850;
}
.life-post__engagement {
display: flex;
flex-wrap: wrap;
@@ -2103,11 +2050,105 @@ button:disabled,
gap: 8px;
}
.life-post__engagement-actions {
flex: 1 1 520px;
}
.life-post__metrics {
justify-content: flex-end;
min-width: 0;
}
.life-rating-control {
min-height: 44px;
height: 44px;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
min-width: 0;
max-width: 100%;
padding: 0 8px 0 0;
border: 0;
border-radius: var(--radius-control);
background: var(--surface-soft);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink-soft);
font-weight: 900;
}
.life-rating-control__stars {
min-height: 44px;
height: 44px;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 0;
overflow: hidden;
border-radius: calc(var(--radius-control) - 1px) 0 0 calc(var(--radius-control) - 1px);
}
.life-rating-control__star {
position: relative;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
flex: 0 0 44px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
color: color-mix(in srgb, var(--warning) 78%, var(--ink-soft));
cursor: pointer;
touch-action: manipulation;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease,
transform 0.14s ease;
}
.life-rating-control__star:hover,
.life-rating-control__star:focus-visible,
.life-rating-control__star.is-active {
border-color: transparent;
background: color-mix(in srgb, var(--warning) 16%, var(--surface-soft));
color: color-mix(in srgb, var(--warning) 86%, var(--ink));
}
.life-rating-control__star:hover {
transform: none;
}
.life-rating-control__star:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.life-rating-control__star .ui-icon {
width: 19px;
height: 19px;
}
.life-rating-control__summary {
min-width: 24px;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 2px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 900;
font-variant-numeric: tabular-nums;
line-height: 1.25;
overflow-wrap: anywhere;
white-space: nowrap;
}
.life-icon-button,
.life-metric-button {
position: relative;
@@ -2180,13 +2221,28 @@ button:disabled,
position: relative;
}
.life-post__review-actions {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.life-review-button {
height: 44px;
min-height: 44px;
}
.life-reaction-control {
height: 44px;
display: inline-flex;
align-items: stretch;
overflow: visible;
border: 1px solid var(--line);
border: 0;
border-radius: var(--radius-control);
background: var(--surface-soft);
box-shadow: inset 0 0 0 1px var(--line);
}
.life-reaction-control .life-icon-button {
@@ -5939,6 +5995,11 @@ button:disabled,
justify-content: flex-start;
}
.life-rating-control {
flex-wrap: nowrap;
justify-content: flex-start;
}
.life-toolbar,
.life-toolbar__search,
.life-toolbar__filters {

View File

@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
@@ -25,8 +26,6 @@ import {
iconReply,
iconSave,
iconSearch,
iconStar,
iconStarOutline,
iconVersion,
iconWarning
} from '../icons';
@@ -727,21 +726,6 @@ function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
function ratingButtonLabel(post: LifePost, rating: number) {
return post.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
}
function ratingAverageLabel(post: LifePost) {
if (post.ratingAverage === null || post.ratingCount === 0) {
return t('pages.life.noRatings');
}
return t('pages.life.ratingAverage', {
average: new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(post.ratingAverage),
count: post.ratingCount
});
}
function closeReactionPicker() {
reactionPickerPostId.value = null;
}
@@ -1256,21 +1240,6 @@ onUnmounted(() => {
</div>
</header>
<div class="life-post__moderation">
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
<button
v-if="canRetryModeration(post)"
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="moderationBusyPostId === post.id"
@click="retryPostModeration(post)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</button>
</div>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
@@ -1285,28 +1254,18 @@ onUnmounted(() => {
<p>{{ post.gameVersion.changeLog }}</p>
</details>
<div v-if="post.category?.isRateable" class="life-rating">
<div class="life-rating__stars" role="group" :aria-label="t('pages.life.rating')">
<button
v-for="rating in 5"
:key="rating"
class="life-rating__star"
:class="{ 'is-active': post.myRating !== null && rating <= post.myRating }"
type="button"
:aria-label="ratingButtonLabel(post, rating)"
:aria-pressed="post.myRating === rating"
:disabled="!canUseRatings(post) || isRatingBusy(post.id)"
@click="toggleRating(post, rating)"
>
<Icon :icon="post.myRating !== null && rating <= post.myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
</button>
</div>
<span class="life-rating__summary">{{ ratingAverageLabel(post) }}</span>
</div>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<div class="life-post__engagement">
<div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions">
<div class="life-reaction-control">
<button
@@ -1379,6 +1338,23 @@ onUnmounted(() => {
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</span>
</button>
<div class="life-post__review-actions">
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
<button
v-if="canRetryModeration(post)"
class="life-icon-button life-review-button"
type="button"
:aria-label="moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="moderationBusyPostId === post.id"
@click="retryPostModeration(post)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
</div>
<div class="life-post__metrics">
@@ -1416,6 +1392,8 @@ onUnmounted(() => {
</div>
</div>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
<section