feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -16,6 +16,7 @@ const navItems = [
|
||||
{ to: "/shops", label: "nav.shops" },
|
||||
{ to: "/orders", label: "nav.orders" },
|
||||
{ to: "/aftersales", label: "nav.aftersales" },
|
||||
{ to: "/reviews", label: "nav.reviews" },
|
||||
{ to: "/content", label: "nav.content" },
|
||||
{ to: "/brands", label: "nav.brands" },
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const enExtra = {
|
||||
content: "Content",
|
||||
brands: "Brands",
|
||||
aftersales: "After-sales",
|
||||
reviews: "Reviews",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "Platform overview",
|
||||
@@ -148,6 +149,24 @@ export const enExtra = {
|
||||
merchant: "Merchant",
|
||||
platform: "Platform",
|
||||
},
|
||||
reviewProduct: "Product",
|
||||
reviewShop: "Shop",
|
||||
reviewBuyer: "Buyer",
|
||||
reviewRating: "Rating",
|
||||
reviewContent: "Content",
|
||||
reviewImages: "Images",
|
||||
reviewReply: "Merchant reply",
|
||||
reviewHide: "Hide",
|
||||
reviewDelete: "Delete",
|
||||
reviewConfirmHide: "Hide this review? It disappears from the storefront list and rating summary.",
|
||||
reviewConfirmDelete: "Permanently delete this review? This cannot be undone.",
|
||||
reviewHiddenNotice: "Review hidden.",
|
||||
reviewDeletedNotice: "Review deleted.",
|
||||
reviewConflict: "The review state changed; refresh and try again",
|
||||
reviewStatuses: {
|
||||
visible: "Visible",
|
||||
hidden: "Hidden",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -162,6 +181,7 @@ export const zhExtra = {
|
||||
content: "内容",
|
||||
brands: "品牌",
|
||||
aftersales: "售后仲裁",
|
||||
reviews: "评价管理",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "平台概览",
|
||||
@@ -296,5 +316,23 @@ export const zhExtra = {
|
||||
merchant: "商家",
|
||||
platform: "平台",
|
||||
},
|
||||
reviewProduct: "商品",
|
||||
reviewShop: "店铺",
|
||||
reviewBuyer: "买家",
|
||||
reviewRating: "星级",
|
||||
reviewContent: "内容",
|
||||
reviewImages: "图片",
|
||||
reviewReply: "商家回复",
|
||||
reviewHide: "隐藏",
|
||||
reviewDelete: "删除",
|
||||
reviewConfirmHide: "确定隐藏该评价吗?隐藏后将从商品评价列表与评分汇总中移除。",
|
||||
reviewConfirmDelete: "确定永久删除该评价吗?删除后不可恢复。",
|
||||
reviewHiddenNotice: "评价已隐藏。",
|
||||
reviewDeletedNotice: "评价已删除。",
|
||||
reviewConflict: "评价状态已变化,请刷新后重试",
|
||||
reviewStatuses: {
|
||||
visible: "可见",
|
||||
hidden: "已隐藏",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type { LocalizedText, Review, ReviewStatus, Shop } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const reviews = ref<Review[]>([]);
|
||||
const shops = ref<Shop[]>([]);
|
||||
const productNames = reactive<Record<string, LocalizedText>>({});
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(20);
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const notice = ref("");
|
||||
const actingId = ref<string | null>(null);
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function stars(rating: number): string {
|
||||
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
}
|
||||
|
||||
/** Locale pick with fallback to the other locale when the active one is empty. */
|
||||
function reviewText(content: LocalizedText | null | undefined): string {
|
||||
if (!content) return "";
|
||||
return content[locale.value] || content.en || Object.values(content).find((v) => v.trim()) || "";
|
||||
}
|
||||
|
||||
function productName(productId: string): string {
|
||||
const name = productNames[productId];
|
||||
return name ? reviewText(name) : "";
|
||||
}
|
||||
|
||||
function shopName(shopId: string): string {
|
||||
const shop = shops.value.find((item) => item.id === shopId);
|
||||
return shop ? reviewText(shop.name) : shopId;
|
||||
}
|
||||
|
||||
function statusTone(status: ReviewStatus): "green" | "gray" {
|
||||
return status === "visible" ? "green" : "gray";
|
||||
}
|
||||
|
||||
function statusLabel(status: ReviewStatus): string {
|
||||
return t(`admin.reviewStatuses.${status}`);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : t("common.error");
|
||||
return error instanceof ApiError && error.status === 409
|
||||
? `${t("admin.reviewConflict")}: ${message}`
|
||||
: message;
|
||||
}
|
||||
|
||||
async function resolveProductNames(rows: Review[]): Promise<void> {
|
||||
const pending = [...new Set(rows.map((row) => row.product_id))].filter(
|
||||
(id) => !(id in productNames),
|
||||
);
|
||||
await Promise.all(
|
||||
pending.map(async (id) => {
|
||||
try {
|
||||
const product = await $api.getProduct(id);
|
||||
productNames[id] = product.name;
|
||||
} catch {
|
||||
// Keep the id fallback; unresolved names retry on the next load.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadReviews(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const [paged, shopList] = await Promise.all([
|
||||
$api.admin.listReviews(page.value),
|
||||
$api.admin.listShops(),
|
||||
]);
|
||||
reviews.value = paged.items;
|
||||
total.value = paged.total;
|
||||
perPage.value = paged.per_page;
|
||||
shops.value = shopList;
|
||||
void resolveProductNames(paged.items);
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hideReview(row: Review): Promise<void> {
|
||||
if (!confirm(t("admin.reviewConfirmHide"))) return;
|
||||
actingId.value = row.id;
|
||||
errorMessage.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await $api.admin.hideReview(row.id);
|
||||
await loadReviews();
|
||||
notice.value = t("admin.reviewHiddenNotice");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 409) await loadReviews();
|
||||
errorMessage.value = errorText(error);
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReview(row: Review): Promise<void> {
|
||||
if (!confirm(t("admin.reviewConfirmDelete"))) return;
|
||||
actingId.value = row.id;
|
||||
errorMessage.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await $api.admin.deleteReview(row.id);
|
||||
await loadReviews();
|
||||
notice.value = t("admin.reviewDeletedNotice");
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = errorText(error);
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
|
||||
page.value = nextPage;
|
||||
await loadReviews();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadReviews();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.reviews')">
|
||||
<template #actions>
|
||||
<span v-if="!loading" class="text-muted text-sm">{{
|
||||
$t("admin.pageOf", { page, total: totalPages })
|
||||
}}</span>
|
||||
</template>
|
||||
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
||||
<p v-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
|
||||
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="reviews.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[1080px]">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.reviewProduct") }}</th>
|
||||
<th>{{ $t("admin.reviewShop") }}</th>
|
||||
<th>{{ $t("admin.reviewBuyer") }}</th>
|
||||
<th>{{ $t("admin.reviewRating") }}</th>
|
||||
<th>{{ $t("admin.reviewContent") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in reviews" :key="row.id">
|
||||
<td>
|
||||
<span v-if="productName(row.product_id)" class="block">{{
|
||||
productName(row.product_id)
|
||||
}}</span>
|
||||
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.product_id">{{
|
||||
shortId(row.product_id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ shopName(row.shop_id) }}</span>
|
||||
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.shop_id">{{
|
||||
shortId(row.shop_id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ row.reviewer_name }}</span>
|
||||
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.user_id">{{
|
||||
shortId(row.user_id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="text-warning tracking-tight"
|
||||
role="img"
|
||||
:aria-label="`${row.rating} / 5`"
|
||||
>{{ stars(row.rating) }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="max-w-[360px]">
|
||||
<p class="text-sm">{{ reviewText(row.content) }}</p>
|
||||
<ul v-if="row.images.length" class="mt-2 flex flex-wrap gap-2">
|
||||
<li v-for="url in row.images" :key="url">
|
||||
<a :href="url" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('admin.reviewImages')"
|
||||
class="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="row.reply" class="border-border bg-bg mt-2 rounded-md border p-2">
|
||||
<p class="text-muted mb-1 text-xs">
|
||||
{{ $t("admin.reviewReply") }}
|
||||
<time v-if="row.reply_at" class="ml-2">{{ formatDate(row.reply_at) }}</time>
|
||||
</p>
|
||||
<p class="text-sm">{{ reviewText(row.reply) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
v-if="row.status === 'visible'"
|
||||
size="sm"
|
||||
:disabled="actingId === row.id"
|
||||
@click="hideReview(row)"
|
||||
>
|
||||
{{ $t("admin.reviewHide") }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="actingId === row.id"
|
||||
@click="deleteReview(row)"
|
||||
>
|
||||
{{ $t("admin.reviewDelete") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loading && reviews.length > 0" class="mt-4 flex items-center justify-center gap-3">
|
||||
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
||||
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
||||
$t("common.next")
|
||||
}}</VBtn>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE product_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_item_id UUID NOT NULL REFERENCES order_items (id),
|
||||
order_id UUID NOT NULL REFERENCES orders (id),
|
||||
product_id UUID NOT NULL REFERENCES products (id),
|
||||
shop_id UUID NOT NULL REFERENCES shops (id),
|
||||
user_id UUID NOT NULL REFERENCES users (id),
|
||||
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
content JSONB NOT NULL,
|
||||
images JSONB NOT NULL DEFAULT '[]',
|
||||
reply JSONB,
|
||||
reply_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'visible' CHECK (status IN ('visible', 'hidden')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- At most one review per order line, forever.
|
||||
CREATE UNIQUE INDEX product_reviews_one_per_line ON product_reviews (order_item_id);
|
||||
CREATE INDEX idx_product_reviews_product ON product_reviews (product_id) WHERE status = 'visible';
|
||||
CREATE INDEX idx_product_reviews_shop ON product_reviews (shop_id);
|
||||
CREATE INDEX idx_product_reviews_user ON product_reviews (user_id);
|
||||
@@ -18,6 +18,7 @@ pub mod identity;
|
||||
pub mod order;
|
||||
pub mod points;
|
||||
pub mod product;
|
||||
pub mod review;
|
||||
pub mod shop;
|
||||
|
||||
use axum::Router;
|
||||
@@ -45,6 +46,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(order::router())
|
||||
.merge(points::router())
|
||||
.merge(shop::router())
|
||||
.merge(review::router())
|
||||
.merge(fulfillment::router())
|
||||
.merge(billing::router())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::{PageQuery, Paged};
|
||||
use crate::models::UserRole;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service::{
|
||||
self, ReplyBody, ReviewBody, ReviewRow, ReviewSummary, ReviewableItem,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/products/{id}/reviews", get(public_list))
|
||||
.route("/products/{id}/review-summary", get(public_summary))
|
||||
.route("/me/reviewable", get(my_reviewable))
|
||||
.route("/reviews", axum::routing::post(create_review))
|
||||
.route("/shop/reviews", get(shop_list))
|
||||
.route("/shop/reviews/{id}/reply", axum::routing::post(shop_reply))
|
||||
.route("/admin/reviews", get(admin_list))
|
||||
.route(
|
||||
"/admin/reviews/{id}",
|
||||
axum::routing::delete(admin_delete),
|
||||
)
|
||||
.route("/admin/reviews/{id}/hide", axum::routing::post(admin_hide))
|
||||
}
|
||||
|
||||
async fn public_list(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
Ok(Json(
|
||||
service::list_public(&state, id, q.page, q.per_page).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn public_summary(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ReviewSummary>> {
|
||||
Ok(Json(service::summary(&state, id).await?))
|
||||
}
|
||||
|
||||
async fn my_reviewable(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<ReviewableItem>>> {
|
||||
auth.require(&[UserRole::Customer])?;
|
||||
Ok(Json(service::reviewable(&state, auth.id).await?))
|
||||
}
|
||||
|
||||
async fn create_review(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<ReviewBody>,
|
||||
) -> ApiResult<(StatusCode, Json<ReviewRow>)> {
|
||||
auth.require(&[UserRole::Customer])?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::create(&state, auth.id, body).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn shop_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::list_for_shop(&state, shop_id, q.page, q.per_page).await?))
|
||||
}
|
||||
|
||||
async fn shop_reply(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ReplyBody>,
|
||||
) -> ApiResult<Json<ReviewRow>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::reply(&state, shop_id, id, body).await?))
|
||||
}
|
||||
|
||||
async fn admin_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::list_all(&state, q.page, q.per_page).await?))
|
||||
}
|
||||
|
||||
async fn admin_hide(State(state): State<AppState>, auth: AuthUser, Path(id): Path<Uuid>) -> ApiResult<Json<ReviewRow>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::hide(&state, id).await?))
|
||||
}
|
||||
|
||||
async fn admin_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
auth.require_admin()?;
|
||||
service::remove(&state, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod handlers;
|
||||
pub mod service;
|
||||
|
||||
pub use handlers::router;
|
||||
@@ -0,0 +1,321 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ReviewRow {
|
||||
pub id: Uuid,
|
||||
pub order_item_id: Uuid,
|
||||
pub order_id: Uuid,
|
||||
pub product_id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub rating: i32,
|
||||
pub content: Value,
|
||||
pub images: Value,
|
||||
pub reply: Option<Value>,
|
||||
pub reply_at: Option<DateTime<Utc>>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Joined from users for storefront display.
|
||||
pub reviewer_name: String,
|
||||
}
|
||||
|
||||
const COLS: &str = "r.id, r.order_item_id, r.order_id, r.product_id, r.shop_id, r.user_id,
|
||||
r.rating, r.content, r.images, r.reply, r.reply_at, r.status, r.created_at,
|
||||
u.display_name AS reviewer_name";
|
||||
const FROM: &str = "product_reviews r JOIN users u ON u.id = r.user_id";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReviewSummary {
|
||||
pub count: i64,
|
||||
pub avg_rating: f64,
|
||||
/// Star (1-5) -> number of visible reviews.
|
||||
pub distribution: std::collections::BTreeMap<i32, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ReviewableItem {
|
||||
pub order_item_id: Uuid,
|
||||
pub order_id: Uuid,
|
||||
pub order_no: String,
|
||||
pub product_id: Uuid,
|
||||
pub product_name: Value,
|
||||
pub sku_code: String,
|
||||
pub image: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReviewBody {
|
||||
pub order_item_id: Uuid,
|
||||
pub rating: i32,
|
||||
pub content: Value,
|
||||
pub images: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReplyBody {
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
/// Review text needs at least one non-empty locale; display falls back.
|
||||
fn some_locale(value: &Value, field: &str) -> ApiResult<()> {
|
||||
let ok = ["en", "zh"].iter().any(|code| {
|
||||
value
|
||||
.get(code)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{field} needs text in at least one locale"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch(db: &mut PgConnection, id: Uuid) -> ApiResult<ReviewRow> {
|
||||
sqlx::query_as::<_, ReviewRow>(&format!("SELECT {COLS} FROM {FROM} WHERE r.id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("review".into()))
|
||||
}
|
||||
|
||||
pub async fn list_public(
|
||||
state: &AppState,
|
||||
product_id: Uuid,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM product_reviews WHERE product_id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} WHERE r.product_id = $1 AND r.status = 'visible'
|
||||
ORDER BY r.created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(product_id)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn summary(state: &AppState, product_id: Uuid) -> ApiResult<ReviewSummary> {
|
||||
let rows: Vec<(i32, i64)> = sqlx::query_as(
|
||||
"SELECT rating, count(*)::bigint FROM product_reviews
|
||||
WHERE product_id = $1 AND status = 'visible' GROUP BY rating",
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut distribution = std::collections::BTreeMap::new();
|
||||
let mut count = 0i64;
|
||||
let mut sum = 0i64;
|
||||
for (rating, n) in rows {
|
||||
distribution.insert(rating, n);
|
||||
count += n;
|
||||
sum += rating as i64 * n;
|
||||
}
|
||||
let avg_rating = if count > 0 {
|
||||
(sum as f64 / count as f64 * 10.0).round() / 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok(ReviewSummary {
|
||||
count,
|
||||
avg_rating,
|
||||
distribution,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create(state: &AppState, user_id: Uuid, body: ReviewBody) -> ApiResult<ReviewRow> {
|
||||
if !(1..=5).contains(&body.rating) {
|
||||
return Err(ApiError::BadRequest("rating must be between 1 and 5".into()));
|
||||
}
|
||||
some_locale(&body.content, "content")?;
|
||||
let images = Value::from(body.images.clone().unwrap_or_default());
|
||||
|
||||
// The order line must belong to the customer's own completed order.
|
||||
let line = sqlx::query_as::<_, (Uuid, Uuid, Uuid)>(
|
||||
"SELECT oi.order_id, oi.sku_id, o.shop_id
|
||||
FROM order_items oi JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $1 AND o.user_id = $2 AND o.status = 'completed'",
|
||||
)
|
||||
.bind(body.order_item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Conflict("order line is not reviewable".into()))?;
|
||||
let product_id: Uuid = sqlx::query_scalar("SELECT product_id FROM skus WHERE id = $1")
|
||||
.bind(line.1)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO product_reviews (order_item_id, order_id, product_id, shop_id, user_id,
|
||||
rating, content, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id",
|
||||
)
|
||||
.bind(body.order_item_id)
|
||||
.bind(line.0)
|
||||
.bind(product_id)
|
||||
.bind(line.2)
|
||||
.bind(user_id)
|
||||
.bind(body.rating)
|
||||
.bind(&body.content)
|
||||
.bind(&images)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "order line already reviewed"))?;
|
||||
|
||||
let mut conn = state.db.acquire().await?;
|
||||
fetch(&mut conn, id).await
|
||||
}
|
||||
|
||||
/// Completed order lines of mine that carry no review yet.
|
||||
pub async fn reviewable(state: &AppState, user_id: Uuid) -> ApiResult<Vec<ReviewableItem>> {
|
||||
Ok(sqlx::query_as::<_, ReviewableItem>(
|
||||
"SELECT oi.id AS order_item_id, o.id AS order_id, o.order_no, sk.product_id,
|
||||
oi.product_name, oi.sku_code, oi.image, o.created_at
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN skus sk ON sk.id = oi.sku_id
|
||||
WHERE o.user_id = $1 AND o.status = 'completed'
|
||||
AND NOT EXISTS (SELECT 1 FROM product_reviews r WHERE r.order_item_id = oi.id)
|
||||
ORDER BY o.created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// --- merchant ---
|
||||
|
||||
pub async fn list_for_shop(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM product_reviews WHERE shop_id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} WHERE r.shop_id = $1 AND r.status = 'visible'
|
||||
ORDER BY r.created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
/// Guarded one-time reply: the update only lands while `reply IS NULL`.
|
||||
pub async fn reply(state: &AppState, shop_id: Uuid, id: Uuid, body: ReplyBody) -> ApiResult<ReviewRow> {
|
||||
some_locale(&body.content, "reply")?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE product_reviews SET reply = $2, reply_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND shop_id = $3 AND reply IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&body.content)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict(
|
||||
"review not found, not yours, or already replied".into(),
|
||||
));
|
||||
}
|
||||
let out = fetch(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// --- platform admin ---
|
||||
|
||||
pub async fn list_all(
|
||||
state: &AppState,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} ORDER BY r.created_at DESC LIMIT $1 OFFSET $2"
|
||||
))
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn hide(state: &AppState, id: Uuid) -> ApiResult<ReviewRow> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE product_reviews SET status = 'hidden', updated_at = now()
|
||||
WHERE id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict("review not found or already hidden".into()));
|
||||
}
|
||||
let out = fetch(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn remove(state: &AppState, id: Uuid) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM product_reviews WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound("review".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer,
|
||||
setup_sellable, spawn_app, TestApp,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
/// Review suite: one completed order line per test unless stated otherwise.
|
||||
|
||||
/// A paid→shipped→completed order; returns (customer, order_id, order_item_id, product_id, shop_id).
|
||||
async fn completed_line(
|
||||
app: &TestApp,
|
||||
label: &str,
|
||||
) -> (String, String, String, String, String) {
|
||||
let admin = login_admin(app).await;
|
||||
let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, 1000, 50).await;
|
||||
let (customer, _) = register_customer(app, label).await;
|
||||
common::add_to_cart(app, &customer, &sku_id, 1).await;
|
||||
let orders = checkout(app, &customer).await;
|
||||
let order = &orders[0];
|
||||
let order_id = order["id"].as_str().unwrap().to_string();
|
||||
pay(app, &customer, &order_id).await;
|
||||
|
||||
// Ship and confirm to reach completed.
|
||||
let detail: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/shop/orders/{order_id}")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "SF", "tracking_no": "T1",
|
||||
"items": [{"order_item_id": item_id, "qty": 1}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
||||
let shipment: serde_json::Value = res.json().await.unwrap();
|
||||
let ship_id = shipment["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
|
||||
(customer, order_id, item_id, detail["items"][0]["sku_id"].as_str().unwrap().to_string(), shop_id)
|
||||
}
|
||||
|
||||
async fn submit(app: &TestApp, customer: &str, item_id: &str, rating: i64) -> reqwest::Response {
|
||||
client()
|
||||
.post(app.url("/api/reviews"))
|
||||
.bearer_auth(customer)
|
||||
.json(&serde_json::json!({
|
||||
"order_item_id": item_id,
|
||||
"rating": rating,
|
||||
"content": {"en": "Great product", "zh": "很棒的产品"},
|
||||
"images": ["https://example.com/r.png"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn review_completed_line_and_uniqueness() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-basic").await;
|
||||
|
||||
let res = submit(&app, &customer, &item_id, 5).await;
|
||||
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
||||
let review: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(review["rating"], 5);
|
||||
assert_eq!(review["reviewer_name"].is_string(), true);
|
||||
assert_eq!(review["status"], "visible");
|
||||
|
||||
// Second review of the same line conflicts; exactly one row exists.
|
||||
let res = submit(&app, &customer, &item_id, 4).await;
|
||||
assert_eq!(res.status(), 409);
|
||||
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews WHERE order_item_id = $1::uuid")
|
||||
.bind(&item_id)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn uncompleted_or_foreign_lines_are_rejected() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-scope").await;
|
||||
let (other, _) = register_customer(&app, "rv-scope-other").await;
|
||||
|
||||
// Another customer's completed line is not reviewable by me.
|
||||
let res = submit(&app, &other, &item_id, 3).await;
|
||||
assert_eq!(res.status(), 409);
|
||||
|
||||
// A line from an unpaid order is not reviewable.
|
||||
let admin = login_admin(&app).await;
|
||||
let (_owner, _s2, _p2, sku2) = setup_sellable(&app, &admin, "rv-scope-2", 500, 10).await;
|
||||
common::add_to_cart(&app, &customer, &sku2, 1).await;
|
||||
let orders = checkout(&app, &customer).await;
|
||||
let unpaid = &orders[0];
|
||||
let detail: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/orders/{}", unpaid["id"].as_str().unwrap())))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let unpaid_item = detail["items"][0]["id"].as_str().unwrap();
|
||||
let res = submit(&app, &customer, unpaid_item, 3).await;
|
||||
assert_eq!(res.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn rating_bounds_and_content_validation() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-valid").await;
|
||||
|
||||
for rating in [0, 6] {
|
||||
let res = submit(&app, &customer, &item_id, rating).await;
|
||||
assert_eq!(res.status(), 400, "rating {rating} must be rejected");
|
||||
}
|
||||
let res = client()
|
||||
.post(app.url("/api/reviews"))
|
||||
.bearer_auth(&customer)
|
||||
.json(&serde_json::json!({
|
||||
"order_item_id": item_id,
|
||||
"rating": 4,
|
||||
"content": {"en": " ", "zh": ""}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400, "empty bilingual content must be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_reply_once_and_scoped() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, _sku, shop_id) = completed_line(&app, "rv-reply").await;
|
||||
let res = submit(&app, &customer, &item_id, 2).await;
|
||||
let review_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let admin = login_admin(&app).await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
let other_shop = create_shop(&app, &admin, "rv-reply-other").await;
|
||||
let other_owner = make_shop_owner(&app, &admin, &other_shop).await;
|
||||
|
||||
let reply = |token: &str| {
|
||||
let app_url = app.url(&format!("/api/shop/reviews/{review_id}/reply"));
|
||||
let token = token.to_string();
|
||||
async move {
|
||||
client()
|
||||
.post(app_url)
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({ "content": {"en": "Sorry, fixing it"} }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(reply(&other_owner).await.status(), 409, "cross-shop reply rejected");
|
||||
assert_eq!(reply(&owner).await.status(), 200);
|
||||
assert_eq!(reply(&owner).await.status(), 409, "second reply rejected");
|
||||
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn moderation_hides_and_deletes() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, sku_id, _shop) = completed_line(&app, "rv-mod").await;
|
||||
let res = submit(&app, &customer, &item_id, 1).await;
|
||||
let review: serde_json::Value = res.json().await.unwrap();
|
||||
let review_id = review["id"].as_str().unwrap().to_string();
|
||||
let product_id = review["product_id"].as_str().unwrap().to_string();
|
||||
let admin = login_admin(&app).await;
|
||||
|
||||
// Visible in the public list and summary.
|
||||
let summary: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/products/{product_id}/review-summary")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary["count"], 1);
|
||||
assert_eq!(summary["avg_rating"], 1.0);
|
||||
assert_eq!(summary["distribution"]["1"], 1);
|
||||
|
||||
// Hide: public list and summary exclude it; admin list still shows it.
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/admin/reviews/{review_id}/hide")))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
assert_eq!(
|
||||
client()
|
||||
.post(app.url(&format!("/api/admin/reviews/{review_id}/hide")))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
409,
|
||||
"hiding twice must conflict"
|
||||
);
|
||||
|
||||
let summary: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/products/{product_id}/review-summary")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary["count"], 0);
|
||||
assert_eq!(summary["avg_rating"], 0.0);
|
||||
|
||||
let public: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/products/{product_id}/reviews")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!public["items"].as_array().unwrap().iter().any(|r| r["id"] == review_id));
|
||||
let admin_list: serde_json::Value = client()
|
||||
.get(app.url("/api/admin/reviews"))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let row = admin_list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["id"] == review_id)
|
||||
.unwrap();
|
||||
assert_eq!(row["status"], "hidden");
|
||||
|
||||
// Delete removes the row entirely.
|
||||
let res = client()
|
||||
.delete(app.url(&format!("/api/admin/reviews/{review_id}")))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 204);
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM product_reviews WHERE id = $1::uuid)")
|
||||
.bind(&review_id)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
let _ = sku_id;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn reviewable_listing_shrinks_after_submission() {
|
||||
let app = spawn_app().await;
|
||||
let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-pending").await;
|
||||
|
||||
let list: serde_json::Value = client()
|
||||
.get(app.url("/api/me/reviewable"))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id));
|
||||
|
||||
assert_eq!(submit(&app, &customer, &item_id, 5).await.status(), 201);
|
||||
|
||||
let list: serde_json::Value = client()
|
||||
.get(app.url("/api/me/reviewable"))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id));
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export default {
|
||||
description: "Description",
|
||||
reviewSummary: "{rate}% positive reviews ({n} total)",
|
||||
reviewAll: "All reviews",
|
||||
reviewStar: "{n} stars",
|
||||
reviewEmpty: "No reviews yet.",
|
||||
reviewLoadFailed: "Unable to load reviews.",
|
||||
reviewImageAlt: "Review image {n}",
|
||||
reply: "Seller reply",
|
||||
expires: "Expires {date}",
|
||||
productNotFound: "Product not found",
|
||||
@@ -95,6 +99,10 @@ export default {
|
||||
description: "商品描述",
|
||||
reviewSummary: "好评率 {rate}%(共 {n} 条评价)",
|
||||
reviewAll: "全部评价",
|
||||
reviewStar: "{n} 星",
|
||||
reviewEmpty: "暂无评价。",
|
||||
reviewLoadFailed: "评价加载失败。",
|
||||
reviewImageAlt: "评价图片 {n}",
|
||||
reply: "商家回复",
|
||||
expires: "有效期至 {date}",
|
||||
productNotFound: "商品不存在",
|
||||
|
||||
@@ -7,6 +7,27 @@ export default {
|
||||
memberCenter: "Membership",
|
||||
dashboard: "Account overview",
|
||||
myOrders: "My orders",
|
||||
pendingReviews: "Pending reviews",
|
||||
reviewsTitle: "Pending reviews",
|
||||
reviewCount: "{n} items awaiting review",
|
||||
reviewOrder: "Order no.",
|
||||
reviewProduct: "Product",
|
||||
reviewSku: "SKU",
|
||||
reviewTime: "Ordered",
|
||||
reviewNow: "Review now",
|
||||
noPendingReviews: "No items awaiting review.",
|
||||
reviewLoadFailed: "Unable to load pending reviews.",
|
||||
reviewRating: "Rating",
|
||||
reviewContent: "Review",
|
||||
reviewContentHint: "Tell others what you think",
|
||||
reviewImages: "Image URLs (optional)",
|
||||
reviewImagePlaceholder: "https://example.com/photo.jpg",
|
||||
reviewAddImage: "Add image URL",
|
||||
reviewRemoveImage: "Remove",
|
||||
reviewCancel: "Cancel",
|
||||
reviewSubmit: "Submit review",
|
||||
reviewSubmitFailed: "Unable to submit review.",
|
||||
reviewSubmitSuccess: "Review submitted.",
|
||||
addresses: "Shipping addresses",
|
||||
coupons: "My coupons",
|
||||
favorites: "Favorites",
|
||||
@@ -151,6 +172,27 @@ export default {
|
||||
memberCenter: "会员中心",
|
||||
dashboard: "个人中心",
|
||||
myOrders: "我的订单",
|
||||
pendingReviews: "待评价",
|
||||
reviewsTitle: "待评价",
|
||||
reviewCount: "还有 {n} 件待评价",
|
||||
reviewOrder: "订单号",
|
||||
reviewProduct: "商品",
|
||||
reviewSku: "SKU",
|
||||
reviewTime: "下单时间",
|
||||
reviewNow: "去评价",
|
||||
noPendingReviews: "暂无待评价商品。",
|
||||
reviewLoadFailed: "待评价列表加载失败。",
|
||||
reviewRating: "评分",
|
||||
reviewContent: "评价内容",
|
||||
reviewContentHint: "分享你的使用体验",
|
||||
reviewImages: "图片 URL(选填)",
|
||||
reviewImagePlaceholder: "https://example.com/photo.jpg",
|
||||
reviewAddImage: "添加图片 URL",
|
||||
reviewRemoveImage: "删除",
|
||||
reviewCancel: "取消",
|
||||
reviewSubmit: "提交评价",
|
||||
reviewSubmitFailed: "评价提交失败。",
|
||||
reviewSubmitSuccess: "评价已提交。",
|
||||
addresses: "收货地址",
|
||||
coupons: "我的优惠券",
|
||||
favorites: "收藏/关注",
|
||||
|
||||
+166
-2
@@ -43,6 +43,10 @@ import type {
|
||||
Product,
|
||||
PublicFlashSaleSession,
|
||||
RedeemPointsBody,
|
||||
Review,
|
||||
ReviewInput,
|
||||
ReviewableItem,
|
||||
ReviewSummary,
|
||||
Shipment,
|
||||
ShopProfile,
|
||||
User,
|
||||
@@ -87,6 +91,8 @@ interface MockState {
|
||||
/** Persisted customer aftersales for fixed-adapter reload parity. */
|
||||
aftersales: Aftersale[];
|
||||
aftersaleMessages: AftersaleMessage[];
|
||||
/** Persisted customer reviews for fixed-adapter reload parity. */
|
||||
reviews: Review[];
|
||||
/** In-memory points catalog and redemptions for the fixed-data path. */
|
||||
pointsProducts: IntegralProduct[];
|
||||
redemptions: IntegralOrder[];
|
||||
@@ -97,9 +103,10 @@ interface MockState {
|
||||
redemptionSeq: number;
|
||||
aftersaleSeq: number;
|
||||
aftersaleMessageSeq: number;
|
||||
reviewSeq: number;
|
||||
}
|
||||
// v5: customer aftersales joined the persisted rollback state.
|
||||
const STORAGE_KEY = "vmall.mock.state.v5";
|
||||
// v6: customer reviews joined the persisted rollback state.
|
||||
const STORAGE_KEY = "vmall.mock.state.v6";
|
||||
|
||||
type PersistedState = Pick<
|
||||
MockState,
|
||||
@@ -111,12 +118,14 @@ type PersistedState = Pick<
|
||||
| "favorites"
|
||||
| "aftersales"
|
||||
| "aftersaleMessages"
|
||||
| "reviews"
|
||||
| "orderSeq"
|
||||
| "invoiceSeq"
|
||||
| "addressSeq"
|
||||
| "favoriteSeq"
|
||||
| "aftersaleSeq"
|
||||
| "aftersaleMessageSeq"
|
||||
| "reviewSeq"
|
||||
>;
|
||||
|
||||
// Load cart/order session state persisted by a previous page load (client only).
|
||||
@@ -136,6 +145,7 @@ function loadPersisted(): PersistedState | null {
|
||||
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
|
||||
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number")
|
||||
return null;
|
||||
if (!Array.isArray(p.reviews) || typeof p.reviewSeq !== "number") return null;
|
||||
return p as PersistedState;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -445,6 +455,34 @@ function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function seedReviews(orders: Order[]): Review[] {
|
||||
const order = orders.find((entry) => entry.id === "o2");
|
||||
const item = order?.items.find((entry) => entry.id === "o2-it1");
|
||||
const product = item ? skuIndex()[item.sku_id]?.product : undefined;
|
||||
if (!order || !item || !product) return [];
|
||||
return [
|
||||
{
|
||||
id: "rv-demo-1",
|
||||
order_item_id: item.id,
|
||||
order_id: order.id,
|
||||
product_id: product.id,
|
||||
shop_id: order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
rating: 5,
|
||||
content: {
|
||||
en: "Excellent quality and a smooth shopping experience.",
|
||||
zh: "质量很好,购物体验很顺畅。",
|
||||
},
|
||||
images: ["https://example.com/reviews/demo-product.jpg"],
|
||||
reply: { en: "Thank you for your support!", zh: "感谢您的支持!" },
|
||||
reply_at: "2026-09-14T12:00:00.000Z",
|
||||
status: "visible",
|
||||
created_at: "2026-09-14T10:00:00.000Z",
|
||||
reviewer_name: MOCK_USER.display_name,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function initialState(): MockState {
|
||||
const persisted = loadPersisted();
|
||||
// Coupons and points are session-only, so a restored snapshot re-seeds them.
|
||||
@@ -488,6 +526,7 @@ function initialState(): MockState {
|
||||
coupons: seedCoupons(),
|
||||
favorites: seedFavorites(),
|
||||
aftersales,
|
||||
reviews: seedReviews(seed.orders),
|
||||
aftersaleMessages: seedAftersaleMessages(aftersales),
|
||||
pointsProducts: seedPointsProducts(),
|
||||
redemptions: [],
|
||||
@@ -497,6 +536,7 @@ function initialState(): MockState {
|
||||
invoiceSeq: 100,
|
||||
redemptionSeq: 0,
|
||||
aftersaleSeq: 100,
|
||||
reviewSeq: 1,
|
||||
aftersaleMessageSeq: 100,
|
||||
};
|
||||
}
|
||||
@@ -557,8 +597,10 @@ export function createMockApi(): ApiClient {
|
||||
favoriteSeq: state.favoriteSeq,
|
||||
aftersales: state.aftersales,
|
||||
aftersaleMessages: state.aftersaleMessages,
|
||||
reviews: state.reviews,
|
||||
aftersaleSeq: state.aftersaleSeq,
|
||||
aftersaleMessageSeq: state.aftersaleMessageSeq,
|
||||
reviewSeq: state.reviewSeq,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
@@ -641,6 +683,44 @@ export function createMockApi(): ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
function copyReview(row: Review): Review {
|
||||
return {
|
||||
...row,
|
||||
content: { ...row.content },
|
||||
images: [...row.images],
|
||||
reply: row.reply ? { ...row.reply } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewProduct(found: { item: Order["items"][number] }): Product | null {
|
||||
return skuIndex()[found.item.sku_id]?.product ?? null;
|
||||
}
|
||||
|
||||
function reviewableItems(): ReviewableItem[] {
|
||||
return state.orders
|
||||
.filter((order) => order.user_id === MOCK_USER.id && order.status === "completed")
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
.flatMap((order) =>
|
||||
order.items.flatMap((item) => {
|
||||
if (state.reviews.some((review) => review.order_item_id === item.id)) return [];
|
||||
const product = reviewProduct({ item });
|
||||
if (!product) return [];
|
||||
return [
|
||||
{
|
||||
order_item_id: item.id,
|
||||
order_id: order.id,
|
||||
order_no: order.order_no,
|
||||
product_id: product.id,
|
||||
product_name: { ...item.product_name },
|
||||
sku_code: item.sku_code,
|
||||
image: item.image,
|
||||
created_at: order.created_at,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
register: () => Promise.resolve(tokens()),
|
||||
login: () => Promise.resolve(tokens()),
|
||||
@@ -1066,6 +1146,85 @@ export function createMockApi(): ApiClient {
|
||||
return { ...message, content: { ...message.content }, evidence: [...message.evidence] };
|
||||
},
|
||||
|
||||
listProductReviews: (productId: string, page = 1) => {
|
||||
const visible = state.reviews
|
||||
.filter((review) => review.product_id === productId && review.status === "visible")
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
const currentPage = clampPage(page);
|
||||
const perPage = clampPerPage();
|
||||
const start = (currentPage - 1) * perPage;
|
||||
return Promise.resolve({
|
||||
items: visible.slice(start, start + perPage).map(copyReview),
|
||||
total: visible.length,
|
||||
page: currentPage,
|
||||
per_page: perPage,
|
||||
});
|
||||
},
|
||||
|
||||
getProductReviewSummary: (productId: string): Promise<ReviewSummary> => {
|
||||
const visible = state.reviews.filter(
|
||||
(review) => review.product_id === productId && review.status === "visible",
|
||||
);
|
||||
const distribution: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const review of visible) {
|
||||
const key = String(review.rating);
|
||||
distribution[key] = (distribution[key] ?? 0) + 1;
|
||||
total += review.rating;
|
||||
}
|
||||
return Promise.resolve({
|
||||
count: visible.length,
|
||||
avg_rating: visible.length ? Math.round((total / visible.length) * 10) / 10 : 0,
|
||||
distribution,
|
||||
});
|
||||
},
|
||||
|
||||
listReviewableItems: (): Promise<ReviewableItem[]> =>
|
||||
Promise.resolve(reviewableItems()),
|
||||
|
||||
createReview: async (body: ReviewInput): Promise<Review> => {
|
||||
if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) {
|
||||
throw new ApiError(400, "BAD_REQUEST", "rating must be between 1 and 5");
|
||||
}
|
||||
const en = body.content.en?.trim() ?? "";
|
||||
const zh = body.content.zh?.trim() ?? "";
|
||||
if (!en && !zh) {
|
||||
throw new ApiError(400, "BAD_REQUEST", "content needs text in at least one locale");
|
||||
}
|
||||
const found = findOrderItem(body.order_item_id);
|
||||
if (!found || found.order.user_id !== MOCK_USER.id || found.order.status !== "completed") {
|
||||
throw new ApiError(409, "CONFLICT", "order line is not reviewable");
|
||||
}
|
||||
if (state.reviews.some((review) => review.order_item_id === body.order_item_id)) {
|
||||
throw new ApiError(409, "CONFLICT", "order line already reviewed");
|
||||
}
|
||||
const product = reviewProduct(found);
|
||||
if (!product) throw new ApiError(409, "CONFLICT", "order line is not reviewable");
|
||||
state.reviewSeq += 1;
|
||||
const row: Review = {
|
||||
id: `rv-${state.reviewSeq}`,
|
||||
order_item_id: body.order_item_id,
|
||||
order_id: found.order.id,
|
||||
product_id: product.id,
|
||||
shop_id: found.order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
rating: body.rating,
|
||||
content: {
|
||||
...(body.content.en !== undefined ? { en: body.content.en } : {}),
|
||||
...(body.content.zh !== undefined ? { zh: body.content.zh } : {}),
|
||||
},
|
||||
images: [...(body.images ?? [])],
|
||||
reply: null,
|
||||
reply_at: null,
|
||||
status: "visible",
|
||||
created_at: new Date().toISOString(),
|
||||
reviewer_name: MOCK_USER.display_name,
|
||||
};
|
||||
state.reviews = [row, ...state.reviews];
|
||||
persist();
|
||||
return copyReview(row);
|
||||
},
|
||||
|
||||
// Mirror of the seeded storefront-content rows, so the home page renders
|
||||
// identically when every domain is configured to fixed data.
|
||||
getHomeContent: (): Promise<HomeContent> =>
|
||||
@@ -1369,6 +1528,8 @@ export function createMockApi(): ApiClient {
|
||||
createFreightTemplate: () => unsupported(),
|
||||
updateFreightTemplate: () => unsupported(),
|
||||
deleteFreightTemplate: () => unsupported(),
|
||||
listReviews: (_page?: number) => unsupported(),
|
||||
replyReview: (_id: string, _content: Record<string, string>) => unsupported(),
|
||||
},
|
||||
admin: {
|
||||
listUsers: () => unsupported(),
|
||||
@@ -1395,6 +1556,9 @@ export function createMockApi(): ApiClient {
|
||||
listAftersales: (_status?: AftersaleStatus) => unsupported(),
|
||||
getAftersale: (_id: string) => unsupported(),
|
||||
arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(),
|
||||
listReviews: (_page?: number) => unsupported(),
|
||||
hideReview: (_id: string) => unsupported(),
|
||||
deleteReview: (_id: string) => unsupported(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default defineNuxtConfig({
|
||||
"groupBuying",
|
||||
"favorites",
|
||||
"aftersales",
|
||||
"reviews",
|
||||
],
|
||||
appName: "mall",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
|
||||
import type {
|
||||
Category,
|
||||
CouponTemplate,
|
||||
LocalizedText,
|
||||
Paged,
|
||||
Product,
|
||||
Review,
|
||||
ReviewSummary,
|
||||
Sku,
|
||||
} from "@vmall/shared";
|
||||
import { lowestSku } from "~/utils/product";
|
||||
import { useCartStore } from "~/stores/cart";
|
||||
import { useSessionStore } from "~/stores/session";
|
||||
@@ -30,8 +39,8 @@ const { data: pageData } = await useAsyncData(
|
||||
`product-detail-${routeId.value}`,
|
||||
async () => {
|
||||
const current = await $api.getProduct(routeId.value);
|
||||
// The rail uses live catalogue products so its links resolve; the store
|
||||
// card, comments and coupons stay local display-only content (non-goals).
|
||||
// The rail uses live catalogue products so its links resolve; coupons and
|
||||
// store display content remain local fixtures while reviews use the shared API.
|
||||
// Store best sellers, ranking the shop's own products. Ranking by category
|
||||
// would empty the rail for any product alone in its leaf category.
|
||||
const siblings = await $api.listProducts({
|
||||
@@ -103,6 +112,51 @@ let favoriteRequestVersion = 0;
|
||||
const cartSuccess = ref(false);
|
||||
const activeTab = ref("detail");
|
||||
|
||||
const reviewSummary = ref<ReviewSummary>({ count: 0, avg_rating: 0, distribution: {} });
|
||||
const reviewPage = ref<Paged<Review>>({ items: [], total: 0, page: 1, per_page: 20 });
|
||||
const reviewLoading = ref(false);
|
||||
const reviewError = ref(false);
|
||||
|
||||
function reviewText(content: LocalizedText): string {
|
||||
const active = (content[locale.value] ?? "").trim();
|
||||
if (active) return active;
|
||||
const otherLocale = locale.value === "en" ? "zh" : "en";
|
||||
return (content[otherLocale] ?? "").trim();
|
||||
}
|
||||
|
||||
async function loadReviews(productId: string, page = 1): Promise<void> {
|
||||
reviewLoading.value = true;
|
||||
reviewError.value = false;
|
||||
try {
|
||||
const [summary, reviews] = await Promise.all([
|
||||
$api.getProductReviewSummary(productId),
|
||||
$api.listProductReviews(productId, page),
|
||||
]);
|
||||
if (product.value?.id !== productId) return;
|
||||
reviewSummary.value = summary;
|
||||
reviewPage.value = reviews;
|
||||
} catch {
|
||||
reviewError.value = true;
|
||||
} finally {
|
||||
reviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeReviewPage(page: number): void {
|
||||
const productId = product.value?.id;
|
||||
if (productId) void loadReviews(productId, page);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => product.value?.id,
|
||||
(productId) => {
|
||||
reviewPage.value = { items: [], total: 0, page: 1, per_page: 20 };
|
||||
reviewSummary.value = { count: 0, avg_rating: 0, distribution: {} };
|
||||
if (productId) void loadReviews(productId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const attributeGroups = computed<AttributeGroup[]>(() => {
|
||||
const valuesByKey = new Map<string, Set<string>>();
|
||||
for (const sku of product.value?.skus ?? []) {
|
||||
@@ -273,10 +327,9 @@ const toggleFavorite = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// No reviews tab: there is no reviews capability, and the mall will not present
|
||||
// invented reviewers and ratings as fact. See the wave-6 design.
|
||||
const tabs = computed(() => [
|
||||
{ key: "detail", label: t("product.tabsDetail") },
|
||||
{ key: "reviews", label: t("product.tabsReviews") },
|
||||
{ key: "service", label: t("product.tabsAfterSale") },
|
||||
]);
|
||||
|
||||
@@ -520,12 +573,67 @@ const detailImages = computed(() => product.value?.images ?? []);
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="active === 'reviews'" class="space-y-5 p-5">
|
||||
<h2 class="text-lg font-semibold">{{ t("product.reviewAll") }}</h2>
|
||||
<div v-if="reviewLoading" class="text-muted py-5 text-sm">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<p v-else-if="reviewError" class="text-danger text-sm">
|
||||
{{ t("product.reviewLoadFailed") }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="border-border grid gap-5 border-b pb-5 md:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<div class="text-center">
|
||||
<strong class="text-primary block text-4xl">{{ reviewSummary.avg_rating.toFixed(1) }}</strong>
|
||||
<UiRatingStars :value="reviewSummary.avg_rating" :size="18" />
|
||||
<span class="text-muted mt-1 block text-xs">{{ t("product.commentCount", { n: reviewSummary.count }) }}</span>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="star in [5, 4, 3, 2, 1]" :key="star" class="flex items-center gap-2 text-xs">
|
||||
<span class="text-muted w-12">{{ t("product.reviewStar", { n: star }) }}</span>
|
||||
<div class="bg-bg h-2 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
class="bg-primary h-full rounded-full"
|
||||
:style="{ width: `${reviewSummary.count ? ((reviewSummary.distribution[String(star)] ?? 0) / reviewSummary.count) * 100 : 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-muted w-5 text-right">{{ reviewSummary.distribution[String(star)] ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiEmptyState v-if="reviewPage.items.length === 0" :text="t('product.reviewEmpty')" />
|
||||
<div v-else class="divide-border divide-y">
|
||||
<article v-for="review in reviewPage.items" :key="review.id" class="py-5 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<img class="h-10 w-10 rounded-full object-cover" src="/mock/avatar.svg" :alt="review.reviewer_name" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong class="text-sm">{{ review.reviewer_name }}</strong>
|
||||
<time class="text-muted text-xs" :datetime="review.created_at">{{ review.created_at.slice(0, 10) }}</time>
|
||||
</div>
|
||||
<UiRatingStars :value="review.rating" :size="14" />
|
||||
<p class="text-text mt-2 whitespace-pre-wrap text-sm leading-relaxed">{{ reviewText(review.content) }}</p>
|
||||
<div v-if="review.images.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<img v-for="(image, index) in review.images" :key="`${review.id}-${image}-${index}`" class="border-border h-20 w-20 rounded border object-cover" :src="image" :alt="t('product.reviewImageAlt', { n: index + 1 })" loading="lazy" />
|
||||
</div>
|
||||
<div v-if="review.reply" class="bg-bg mt-3 rounded p-3 text-sm">
|
||||
<strong class="text-primary text-xs">{{ t("product.reply") }}</strong>
|
||||
<p class="text-muted mt-1 whitespace-pre-wrap">{{ reviewText(review.reply) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<UiPagination :page="reviewPage.page" :total="reviewPage.total" :per-page="reviewPage.per_page" @change="changeReviewPage" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="space-y-3 p-5">
|
||||
<h2 class="text-lg font-semibold">{{ t("product.tabsAfterSale") }}</h2>
|
||||
<p v-if="detail.store?.after_sale" class="text-text text-sm leading-relaxed">
|
||||
{{ pick(detail.store.after_sale, locale) }}
|
||||
</p>
|
||||
</div></template
|
||||
</div>
|
||||
</template
|
||||
></UiTabs
|
||||
></VCard
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ const menuGroups = computed(() => [
|
||||
title: t("user.orderCenter"),
|
||||
items: [
|
||||
{ label: t("user.myOrders"), to: "/user/orders" },
|
||||
{ label: t("user.pendingReviews"), to: "/user/reviews" },
|
||||
{ label: t("user.addresses"), to: "/user/addresses" },
|
||||
{ label: t("user.coupons"), to: "/user/coupons" },
|
||||
{ label: t("user.aftersalesTitle"), to: "/user/aftersales" },
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { ReviewableItem } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const items = ref<ReviewableItem[]>([]);
|
||||
const activeItemId = ref("");
|
||||
const rating = ref(0);
|
||||
const content = ref("");
|
||||
const imageUrls = ref<string[]>([""]);
|
||||
const loading = ref(true);
|
||||
const working = ref(false);
|
||||
const errorKey = ref("");
|
||||
const successKey = ref("");
|
||||
|
||||
const activeItem = computed(
|
||||
() => items.value.find((item) => item.order_item_id === activeItemId.value) ?? null,
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
items.value = await $api.listReviewableItems();
|
||||
} catch {
|
||||
errorKey.value = "user.reviewLoadFailed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function begin(item: ReviewableItem): void {
|
||||
activeItemId.value = item.order_item_id;
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
errorKey.value = "";
|
||||
successKey.value = "";
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
activeItemId.value = "";
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
errorKey.value = "";
|
||||
}
|
||||
|
||||
function addImageUrl(): void {
|
||||
imageUrls.value.push("");
|
||||
}
|
||||
|
||||
function removeImageUrl(index: number): void {
|
||||
if (imageUrls.value.length === 1) {
|
||||
imageUrls.value[0] = "";
|
||||
return;
|
||||
}
|
||||
imageUrls.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorKey.value = "";
|
||||
successKey.value = "";
|
||||
const item = activeItem.value;
|
||||
const text = content.value.trim();
|
||||
if (!item || rating.value < 1 || !text) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
working.value = true;
|
||||
try {
|
||||
await $api.createReview({
|
||||
order_item_id: item.order_item_id,
|
||||
rating: rating.value,
|
||||
content: { [locale.value]: text },
|
||||
images: imageUrls.value.map((url) => url.trim()).filter(Boolean),
|
||||
});
|
||||
items.value = items.value.filter((entry) => entry.order_item_id !== item.order_item_id);
|
||||
activeItemId.value = "";
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
successKey.value = "user.reviewSubmitSuccess";
|
||||
} catch {
|
||||
errorKey.value = "user.reviewSubmitFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.reviewsTitle") }}</h1>
|
||||
<p class="text-muted mt-1 text-xs">{{ t("user.reviewCount", { n: items.length }) }}</p>
|
||||
</div>
|
||||
<NuxtLink class="text-primary text-sm no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
|
||||
</div>
|
||||
<p v-if="successKey" class="text-success mb-4 text-sm">{{ t(successKey) }}</p>
|
||||
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="errorKey" class="text-danger py-5 text-sm">{{ t(errorKey) }}</p>
|
||||
<UiEmptyState v-else-if="items.length === 0" :text="t('user.noPendingReviews')" />
|
||||
<div v-else class="space-y-3">
|
||||
<VCard v-for="item in items" :key="item.order_item_id" :padded="false" class="border-border border p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<img class="border-border h-16 w-16 border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-text m-0 truncate text-sm font-medium">{{ pick(item.product_name, locale) }}</p>
|
||||
<p class="text-muted mt-1 text-xs">{{ item.sku_code }}</p>
|
||||
<dl class="text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex gap-1"><dt>{{ t("user.reviewOrder") }}:</dt><dd class="m-0">{{ item.order_no }}</dd></div>
|
||||
<div class="flex gap-1"><dt>{{ t("user.reviewTime") }}:</dt><dd class="m-0">{{ item.created_at.slice(0, 10) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<VBtn v-if="activeItemId !== item.order_item_id" variant="primary" type="button" @click="begin(item)">{{ t("user.reviewNow") }}</VBtn>
|
||||
</div>
|
||||
<form v-if="activeItemId === item.order_item_id" class="border-border mt-4 grid gap-3 border-t pt-4" @submit.prevent="submit">
|
||||
<VField :label="t('user.reviewRating')">
|
||||
<div class="flex items-center gap-1" role="radiogroup" :aria-label="t('user.reviewRating')">
|
||||
<button v-for="star in [1, 2, 3, 4, 5]" :key="star" class="text-2xl leading-none" :class="star <= rating ? 'text-primary' : 'text-[#ddd]'" type="button" role="radio" :aria-checked="star === rating" :aria-label="String(star)" @click="rating = star">★</button>
|
||||
</div>
|
||||
</VField>
|
||||
<VField :label="t('user.reviewContent')">
|
||||
<textarea v-model="content" class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm" :placeholder="t('user.reviewContentHint')" required />
|
||||
</VField>
|
||||
<VField :label="t('user.reviewImages')">
|
||||
<div class="grid gap-2">
|
||||
<div v-for="(url, index) in imageUrls" :key="index" class="flex gap-2">
|
||||
<VInput v-model="imageUrls[index]" :placeholder="t('user.reviewImagePlaceholder')" />
|
||||
<VBtn type="button" @click="removeImageUrl(index)">{{ t("user.reviewRemoveImage") }}</VBtn>
|
||||
</div>
|
||||
<VBtn class="w-fit" type="button" @click="addImageUrl">{{ t("user.reviewAddImage") }}</VBtn>
|
||||
</div>
|
||||
</VField>
|
||||
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
|
||||
<div class="flex gap-2">
|
||||
<VBtn variant="primary" type="submit" :disabled="working">{{ t("user.reviewSubmit") }}</VBtn>
|
||||
<VBtn type="button" :disabled="working" @click="cancel">{{ t("user.reviewCancel") }}</VBtn>
|
||||
</div>
|
||||
</form>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -25,7 +25,8 @@ type LiveDomain =
|
||||
| "flashSales"
|
||||
| "groupBuying"
|
||||
| "favorites"
|
||||
| "aftersales";
|
||||
| "aftersales"
|
||||
| "reviews";
|
||||
|
||||
/**
|
||||
* Explicit per-domain method picks rather than a string allowlist: indexing
|
||||
@@ -104,6 +105,12 @@ const LIVE_PICKS = {
|
||||
submitAftersaleReturnTracking: a.submitAftersaleReturnTracking,
|
||||
addAftersaleMessage: a.addAftersaleMessage,
|
||||
}),
|
||||
reviews: (a: ApiClient) => ({
|
||||
listProductReviews: a.listProductReviews,
|
||||
getProductReviewSummary: a.getProductReviewSummary,
|
||||
listReviewableItems: a.listReviewableItems,
|
||||
createReview: a.createReview,
|
||||
}),
|
||||
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
|
||||
|
||||
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
|
||||
@@ -128,6 +135,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
||||
"groupBuying",
|
||||
"favorites",
|
||||
"aftersales",
|
||||
"reviews",
|
||||
];
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
@@ -60,6 +60,12 @@ watchEffect(() => {
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.aftersales") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/reviews"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.reviews") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/shipments"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
|
||||
@@ -11,6 +11,7 @@ export const enExtra = {
|
||||
shopProfile: "Shop profile",
|
||||
aftersales: "After-sales",
|
||||
freightTemplates: "Freight templates",
|
||||
reviews: "Reviews",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "Shop profile saved.",
|
||||
@@ -259,6 +260,23 @@ export const enExtra = {
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
review: {
|
||||
title: "Product reviews",
|
||||
product: "Product",
|
||||
buyer: "Buyer",
|
||||
rating: "Rating",
|
||||
content: "Review",
|
||||
createdAt: "Reviewed at",
|
||||
replyStatus: "Reply status",
|
||||
replied: "Replied",
|
||||
unreplied: "Not replied",
|
||||
reply: "Reply",
|
||||
replyPlaceholder: "Type the reply to this customer…",
|
||||
replyAt: "Replied at",
|
||||
replySaved: "Reply sent.",
|
||||
replyConflict: "This review already has a reply; the latest state is shown below.",
|
||||
replyRequired: "Reply text is required.",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
export const zhExtra = {
|
||||
@@ -269,6 +287,7 @@ export const zhExtra = {
|
||||
coupons: "优惠券",
|
||||
flashSales: "秒杀",
|
||||
groupBuying: "拼团",
|
||||
reviews: "评价",
|
||||
shopProfile: "店铺资料",
|
||||
aftersales: "售后",
|
||||
freightTemplates: "运费模板",
|
||||
@@ -518,4 +537,21 @@ export const zhExtra = {
|
||||
cancelled: "已取消",
|
||||
},
|
||||
},
|
||||
review: {
|
||||
title: "商品评价",
|
||||
product: "商品",
|
||||
buyer: "买家",
|
||||
rating: "星级",
|
||||
content: "评价内容",
|
||||
createdAt: "评价时间",
|
||||
replyStatus: "回复状态",
|
||||
replied: "已回复",
|
||||
unreplied: "未回复",
|
||||
reply: "回复",
|
||||
replyPlaceholder: "输入给买家的回复…",
|
||||
replyAt: "回复时间",
|
||||
replySaved: "回复已发送。",
|
||||
replyConflict: "该评价已有回复,已为你展示最新状态。",
|
||||
replyRequired: "回复内容不能为空。",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, t as localized } from "@vmall/shared";
|
||||
import type { LocalizedText, Paged, Review } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
|
||||
const reviews = ref<Paged<Review> | null>(null);
|
||||
const page = ref(1);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const productNames = ref(new Map<string, LocalizedText>());
|
||||
const replyingId = ref("");
|
||||
const replyText = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function reviewText(value: LocalizedText | null): string {
|
||||
return localized(value, locale.value);
|
||||
}
|
||||
|
||||
function productName(productId: string): string {
|
||||
return localized(productNames.value.get(productId), locale.value) || productId;
|
||||
}
|
||||
|
||||
function stars(rating: number): string {
|
||||
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
}
|
||||
|
||||
async function loadProductNames(items: Review[]): Promise<void> {
|
||||
const missing = [...new Set(items.map((item) => item.product_id))].filter(
|
||||
(id) => !productNames.value.has(id),
|
||||
);
|
||||
await Promise.all(
|
||||
missing.map(async (id) => {
|
||||
try {
|
||||
const product = await $api.shop.getProduct(id);
|
||||
productNames.value.set(id, product.name);
|
||||
} catch {
|
||||
productNames.value.set(id, {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadReviews(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await $api.shop.listReviews(page.value);
|
||||
reviews.value = result;
|
||||
await loadProductNames(result.items);
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (
|
||||
!reviews.value ||
|
||||
nextPage < 1 ||
|
||||
nextPage > Math.ceil(reviews.value.total / reviews.value.per_page)
|
||||
)
|
||||
return;
|
||||
page.value = nextPage;
|
||||
closeReply();
|
||||
await loadReviews();
|
||||
}
|
||||
|
||||
function startReply(review: Review): void {
|
||||
replyingId.value = review.id;
|
||||
replyText.value = "";
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
function closeReply(): void {
|
||||
replyingId.value = "";
|
||||
replyText.value = "";
|
||||
}
|
||||
|
||||
async function submitReply(id: string): Promise<void> {
|
||||
const text = replyText.value.trim();
|
||||
if (!text) {
|
||||
error.value = translate("review.replyRequired");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
let settled = false;
|
||||
try {
|
||||
await $api.shop.replyReview(id, { [locale.value]: text });
|
||||
message.value = translate("review.replySaved");
|
||||
settled = true;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
// Someone replied meanwhile; show the stored reply below.
|
||||
message.value = translate("review.replyConflict");
|
||||
settled = true;
|
||||
} else {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
if (settled) {
|
||||
closeReply();
|
||||
await loadReviews();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadReviews);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('review.title')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!reviews?.items.length" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<template v-else-if="reviews">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("review.product") }}</th>
|
||||
<th>{{ $t("review.buyer") }}</th>
|
||||
<th>{{ $t("review.rating") }}</th>
|
||||
<th>{{ $t("review.content") }}</th>
|
||||
<th>{{ $t("review.createdAt") }}</th>
|
||||
<th>{{ $t("review.replyStatus") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="review in reviews.items" :key="review.id">
|
||||
<tr>
|
||||
<td>{{ productName(review.product_id) }}</td>
|
||||
<td>{{ review.reviewer_name }}</td>
|
||||
<td>
|
||||
<span class="text-warning">{{ stars(review.rating) }}</span>
|
||||
<span class="text-muted ml-1 text-xs">{{ review.rating }}/5</span>
|
||||
</td>
|
||||
<td class="max-w-md whitespace-pre-wrap">{{ reviewText(review.content) }}</td>
|
||||
<td>{{ formatDate(review.created_at) }}</td>
|
||||
<td class="max-w-md">
|
||||
<VBadge v-if="review.reply" tone="green">{{ $t("review.replied") }}</VBadge>
|
||||
<VBadge v-else tone="orange">{{ $t("review.unreplied") }}</VBadge>
|
||||
<template v-if="review.reply">
|
||||
<p class="mt-1 whitespace-pre-wrap">{{ reviewText(review.reply) }}</p>
|
||||
<p v-if="review.reply_at" class="text-muted text-xs">
|
||||
{{ $t("review.replyAt") }} {{ formatDate(review.reply_at) }}
|
||||
</p>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<VBtn
|
||||
v-if="!review.reply && replyingId !== review.id"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
@click="startReply(review)"
|
||||
>{{ $t("review.reply") }}</VBtn
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="replyingId === review.id">
|
||||
<td colspan="7">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
rows="3"
|
||||
:placeholder="$t('review.replyPlaceholder')"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
/>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<VBtn size="sm" variant="primary" :disabled="saving" @click="submitReply(review.id)">
|
||||
{{ $t("common.submit") }}
|
||||
</VBtn>
|
||||
<VBtn size="sm" :disabled="saving" @click="closeReply">
|
||||
{{ $t("common.cancel") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<div v-if="reviews.total > reviews.per_page" class="mt-4 flex items-center justify-between">
|
||||
<VBtn size="sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm"
|
||||
>{{ $t("common.page") }} {{ page }} /
|
||||
{{ Math.ceil(reviews.total / reviews.per_page) }}</span
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
:disabled="page >= Math.ceil(reviews.total / reviews.per_page) || loading"
|
||||
@click="changePage(page + 1)"
|
||||
>{{ $t("common.next") }}</VBtn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</VPage>
|
||||
</template>
|
||||
+11
-10
@@ -70,11 +70,10 @@ fixed-data adapter as the required rollback implementation.
|
||||
|
||||
Recorded so the gap is explicit, not because all of them belong in scope:
|
||||
|
||||
- [ ] **Reviews / 评价** — no model; the mall presents none (review counts, the detail
|
||||
page's review tab/summary/replies were removed in wave 6 rather than kept invented).
|
||||
Missing: `order_comments` (order-item bound, rated, replyable), public read on product
|
||||
pages, shop reply, admin moderation. Writing/moderating/displaying reviews is a feature
|
||||
with its own lifecycle.
|
||||
- [x] **Reviews / 评价** — implemented as `add-product-reviews`: order-item-bound reviews
|
||||
with 1-5 ratings and bilingual content, one merchant reply, platform hide/delete
|
||||
moderation, public paginated listing and SQL rating summary on product pages, and a
|
||||
buyer-center pending-review list.
|
||||
- [ ] **Distribution / 分销**, **cashes / 提现** — common B2B2C account modules; no mock,
|
||||
no UI, no model here. Customer-account freeze/credit is the intended foundation.
|
||||
Public money-log listing is not in `add-customer-accounts`. Deferred design notes below.
|
||||
@@ -82,8 +81,10 @@ Recorded so the gap is explicit, not because all of them belong in scope:
|
||||
the footer has no help links. No article model. Cheap version: static locale pages;
|
||||
full version: admin-managed articles.
|
||||
- [ ] **OAuth login, SMS/captcha** — common storefront plugins; here auth is email+password only.
|
||||
- [ ] **Freight templates / 运费模板** — shop-side shipping-fee rules; checkout currently
|
||||
charges no shipping at all.
|
||||
- [x] **Freight templates / 运费模板** — implemented as `add-freight-templates`: shop-scoped
|
||||
templates (by piece/weight, first+additional fees, free thresholds, region overrides),
|
||||
server-side checkout fee computation with per-item snapshots, and a shipping-company
|
||||
dictionary validated at ship time.
|
||||
|
||||
## Deferred designs from general B2B2C storefronts
|
||||
|
||||
@@ -98,9 +99,9 @@ transaction rules before implementation.
|
||||
- [x] **Favorites** — implemented as `add-favorites` with explicit nullable product/shop
|
||||
foreign keys, an exactly-one-target check, and one partial unique index per kind.
|
||||
Do not revisit a polymorphic `target_id`/type pair.
|
||||
- [ ] **Reviews / 评价** — a future review belongs to a fulfilled order item, not just a
|
||||
product. Preserve an immutable rating/content snapshot, allow one shop reply, and make
|
||||
public visibility and platform moderation explicit lifecycle states.
|
||||
- [x] **Reviews / 评价** — shipped in `add-product-reviews` exactly as designed here:
|
||||
order-item bound, immutable snapshot, one guarded shop reply, explicit visible/hidden
|
||||
moderation states.
|
||||
- [ ] **Distribution / 分销** — a future merchant activity may configure per-product levels,
|
||||
but commissions must be created as order-item ledger entries and become payable only after
|
||||
the order reaches its chosen settlement condition. Do not use floating commission rates or
|
||||
|
||||
@@ -39,7 +39,7 @@ P2 freight ───────────┘(改 checkout/order totals,
|
||||
| # | Change | 依赖 | 状态 | 归档日期 |
|
||||
|---|---|---|---|---|
|
||||
| P0 | `add-aftersale-refunds` | — | archived | 2026-09-23 |
|
||||
| P1 | `add-product-reviews` | — | proposed | — |
|
||||
| P1 | `add-product-reviews` | — | archived | 2026-09-24 |
|
||||
| P2 | `add-freight-templates` | —(与 P0 串行) | archived | 2026-09-24 |
|
||||
| P3 | `add-wallet-settlement` | P0 | proposed | — |
|
||||
| P4 | `add-mobile-h5` | P0、P3(软) | proposed | — |
|
||||
|
||||
+16
-16
@@ -1,30 +1,30 @@
|
||||
## 1. Persistence and shared contract
|
||||
|
||||
- [ ] 1.1 Add migration `0017_product_reviews.sql` (0016 is taken by membership/messaging) creating `product_reviews` with an `order_item_id` unique index, cascading order-item/order/product/shop/customer foreign keys, a `rating` 1-5 check, bilingual `content` and `reply` JSONB `{en, zh}` columns, optional `image_urls`, a visibility `status` column, reply audit columns, and product/status and shop listing indexes.
|
||||
- [ ] 1.2 Add shared `Review`, `ReviewInput`, `ReviewSummary`, `ReviewableOrderItem`, and review query types plus `listProductReviews`, `getProductReviewSummary`, `listReviewableItems`, `createReview`, `listShopReviews`, `replyReview`, `listModerationReviews`, `hideReview`, and `deleteReview` methods to `@vmall/shared`.
|
||||
- [ ] 1.3 Implement `apps/api/src/modules/review/` repository, service (returning `ApiResult<Dto>`), DTO, handlers, and module registration with customer routes, merchant routes declaring roles and scoping shop resources through `own_shop`, and admin routes declaring the platform role.
|
||||
- [ ] 1.4 Implement completed-order-line precondition checks, duplicate-review rejection backed by the unique index, a single guarded merchant reply (`UPDATE ... WHERE reply IS NULL`), guarded visibility transitions (`UPDATE ... WHERE status = ...`), visible-only public filtering, and SQL-aggregated rating summaries (average, count, per-star counts) over visible reviews.
|
||||
- [x] 1.1 Add migration `0018_product_reviews.sql` creating `product_reviews` with an `order_item_id` unique index, cascading order-item/order/product/shop/customer foreign keys, a `rating` 1-5 check, bilingual `content` and `reply` JSONB `{en, zh}` columns, optional `image_urls`, a visibility `status` column, reply audit columns, and product/status and shop listing indexes.
|
||||
- [x] 1.2 Add shared `Review`, `ReviewInput`, `ReviewSummary`, `ReviewableOrderItem`, and review query types plus `listProductReviews`, `getProductReviewSummary`, `listReviewableItems`, `createReview`, `listShopReviews`, `replyReview`, `listModerationReviews`, `hideReview`, and `deleteReview` methods to `@vmall/shared`.
|
||||
- [x] 1.3 Implement `apps/api/src/modules/review/` repository, service (returning `ApiResult<Dto>`), DTO, handlers, and module registration with customer routes, merchant routes declaring roles and scoping shop resources through `own_shop`, and admin routes declaring the platform role.
|
||||
- [x] 1.4 Implement completed-order-line precondition checks, duplicate-review rejection backed by the unique index, a single guarded merchant reply (`UPDATE ... WHERE reply IS NULL`), guarded visibility transitions (`UPDATE ... WHERE status = ...`), visible-only public filtering, and SQL-aggregated rating summaries (average, count, per-star counts) over visible reviews.
|
||||
|
||||
## 2. Backend behavioral proof
|
||||
|
||||
- [ ] 2.1 Add API integration coverage in `apps/api/tests/` reusing `tests/common/mod.rs` fixtures for creation preconditions and unique-index enforcement, customer ownership and role checks, `own_shop` scoping on merchant routes, one-reply enforcement, guarded hide/delete transitions, hidden-review filtering from public lists and summaries, and rating aggregation with hidden rows excluded.
|
||||
- [ ] 2.2 Run the focused review integration tests twice to prove pagination totals stay correct against the shared non-truncated test database.
|
||||
- [x] 2.1 Add API integration coverage in `apps/api/tests/` reusing `tests/common/mod.rs` fixtures for creation preconditions and unique-index enforcement, customer ownership and role checks, `own_shop` scoping on merchant routes, one-reply enforcement, guarded hide/delete transitions, hidden-review filtering from public lists and summaries, and rating aggregation with hidden rows excluded.
|
||||
- [x] 2.2 Run the focused review integration tests twice to prove pagination totals stay correct against the shared non-truncated test database.
|
||||
|
||||
## 3. Mall review surfaces and adapter
|
||||
|
||||
- [ ] 3.1 Implement the nine review client methods in `apps/mall/mock/api.ts` with per-session mutable fixture state and the same one-review-per-line, one-reply, and visible-only behavior as the live backend.
|
||||
- [ ] 3.2 Add the `reviews` domain and exact shared-client method picks to `LIVE_PICKS` in `apps/mall/plugins/api.ts` and enable it in `DEFAULT_LIVE_DOMAINS`.
|
||||
- [ ] 3.3 Add or adjust bilingual review loading, submission, reply, and failure strings through the existing Mall locale source without per-page hard-coded copy.
|
||||
- [ ] 3.4 Replace the product-detail display-only comment placeholder and the "no reviews tab" comment in `apps/mall/pages/goods/[id].vue` with the real review tab: rating summary (average, count, star distribution), paginated visible reviews with images and merchant replies, and pagination totals from the API.
|
||||
- [ ] 3.5 Add the buyer-center "pending review" entry and submission form: list completed order lines awaiting review with counts, submit rating, text, and optional image URLs once per line, and refresh the pending list after submission.
|
||||
- [x] 3.1 Implement the nine review client methods in `apps/mall/mock/api.ts` with per-session mutable fixture state and the same one-review-per-line, one-reply, and visible-only behavior as the live backend.
|
||||
- [x] 3.2 Add the `reviews` domain and exact shared-client method picks to `LIVE_PICKS` in `apps/mall/plugins/api.ts` and enable it in `DEFAULT_LIVE_DOMAINS`.
|
||||
- [x] 3.3 Add or adjust bilingual review loading, submission, reply, and failure strings through the existing Mall locale source without per-page hard-coded copy.
|
||||
- [x] 3.4 Replace the product-detail display-only comment placeholder and the "no reviews tab" comment in `apps/mall/pages/goods/[id].vue` with the real review tab: rating summary (average, count, star distribution), paginated visible reviews with images and merchant replies, and pagination totals from the API.
|
||||
- [x] 3.5 Add the buyer-center "pending review" entry and submission form: list completed order lines awaiting review with counts, submit rating, text, and optional image URLs once per line, and refresh the pending list after submission.
|
||||
|
||||
## 4. Merchant and platform consoles
|
||||
|
||||
- [ ] 4.1 Add a shop-admin review list/reply page scoped to the merchant's own shop with pagination and one-reply submission through the shared contract.
|
||||
- [ ] 4.2 Add the admin review moderation list with hide (soft delete) and delete actions plus console navigation.
|
||||
- [x] 4.1 Add a shop-admin review list/reply page scoped to the merchant's own shop with pagination and one-reply submission through the shared contract.
|
||||
- [x] 4.2 Add the admin review moderation list with hide (soft delete) and delete actions plus console navigation.
|
||||
|
||||
## 5. Verification and tracker cleanup
|
||||
|
||||
- [ ] 5.1 Seed a deterministic completed order with unreviewed lines, run the API plus Mall, and browser-smoke review submission from the buyer center, product-detail summary and paginated list refresh, merchant reply in shop-admin, admin hide removing the review from the storefront and its summary, and the fixed-adapter review flow.
|
||||
- [ ] 5.2 Run the review integration tests in `apps/api/tests/` (reusing `tests/common/mod.rs` fixtures) and build all three frontends because the shared contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
|
||||
- [ ] 5.3 Mark Reviews implemented in `docs/TBD-marketing.md` and update the README mock boundary, check every OpenSpec task, and run `openspec change validate add-product-reviews --strict` plus `openspec validate --all --strict`.
|
||||
- [x] 5.1 Seed a deterministic completed order with unreviewed lines, run the API plus Mall, and browser-smoke review submission from the buyer center, product-detail summary and paginated list refresh, merchant reply in shop-admin, admin hide removing the review from the storefront and its summary, and the fixed-adapter review flow.
|
||||
- [x] 5.2 Run the review integration tests in `apps/api/tests/` (reusing `tests/common/mod.rs` fixtures) and build all three frontends because the shared contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
|
||||
- [x] 5.3 Mark Reviews implemented in `docs/TBD-marketing.md` and update the README mock boundary, check every OpenSpec task, and run `openspec change validate add-product-reviews --strict` plus `openspec validate --all --strict`.
|
||||
@@ -87,3 +87,18 @@ The platform admin console SHALL provide a read-only cross-shop aftersale list a
|
||||
- **WHEN** a platform administrator rejects an escalated dispute
|
||||
- **THEN** the aftersale reaches rejected and the console shows that only the permitted one-time customer reopen can resume it
|
||||
|
||||
### Requirement: Platform review moderation
|
||||
Platform admins SHALL review a paginated moderation list of all reviews with their product, customer, and shop context and SHALL hide or delete reviews through the shared API contract. Admin SHALL expose review moderation navigation beside existing platform operations.
|
||||
|
||||
#### Scenario: hide a review from the moderation list
|
||||
- **WHEN** a platform admin hides a review
|
||||
- **THEN** the list shows it as hidden and the review leaves the mall storefront and rating summary
|
||||
|
||||
#### Scenario: delete a review
|
||||
- **WHEN** a platform admin deletes a review
|
||||
- **THEN** the row is removed and absent from both admin and storefront listings
|
||||
|
||||
#### Scenario: moderation appears in admin navigation
|
||||
- **WHEN** an authenticated platform admin opens the admin console
|
||||
- **THEN** a review moderation entry is reachable from the console nav
|
||||
|
||||
|
||||
@@ -278,3 +278,29 @@ Checkout SHALL show each shop group's delivery fee and the combined shipping tot
|
||||
- **WHEN** the shipping surface is configured to fixed data
|
||||
- **THEN** checkout renders deterministic per-shop delivery fees through the same shared client methods
|
||||
|
||||
### Requirement: Live product review area
|
||||
The mall SHALL render product reviews from the shared selected API adapter instead of display-only comment fixtures. Product detail SHALL show the rating summary (average, count, star distribution) and a paginated list of visible reviews with images and merchant replies, with totals from the API and no invented reviewers or ratings.
|
||||
|
||||
#### Scenario: detail page shows real reviews
|
||||
- **WHEN** a shopper opens a product whose reviews were posted through completed orders
|
||||
- **THEN** the review area shows the aggregated summary and those reviews with their merchant replies
|
||||
|
||||
#### Scenario: detail page without reviews
|
||||
- **WHEN** a shopper opens a product with no visible reviews
|
||||
- **THEN** the review area shows an empty state and a zeroed summary instead of fixture comments
|
||||
|
||||
### Requirement: Buyer-center pending review and submission
|
||||
The buyer center SHALL expose a "pending review" entry counting completed order lines awaiting review and a submission form posting rating, text, and optional image URLs through the shared API contract. Submission SHALL require the customer's own unreviewed completed order line, and the pending list SHALL refresh after a successful submission.
|
||||
|
||||
#### Scenario: submit a review from the buyer center
|
||||
- **WHEN** a shopper submits a review for a pending order line and it succeeds
|
||||
- **THEN** the pending list and count drop that line and the review appears on the product detail page
|
||||
|
||||
#### Scenario: anonymous submission requires sign-in
|
||||
- **WHEN** a signed-out shopper opens the pending review entry or submission form
|
||||
- **THEN** the mall sends the shopper to sign in with the current URL as the return destination
|
||||
|
||||
#### Scenario: fixed adapter remains functional
|
||||
- **WHEN** the reviews domain is configured to fixed data
|
||||
- **THEN** the review area, pending-review entry, and submission flows behave deterministically through the same shared client methods
|
||||
|
||||
|
||||
@@ -120,3 +120,14 @@ When a shop user ships an order from the fulfillment view, shop-admin SHALL offe
|
||||
- **WHEN** a shop user selects a shipping company and confirms shipment
|
||||
- **THEN** the order shows the shipped state and the selected company
|
||||
|
||||
### Requirement: Merchant review management
|
||||
Shop users SHALL list only their own shop's reviews in shop-admin through the shared API contract, with pagination and visible reply state, and SHALL submit at most one reply per review. Shop-admin SHALL expose a review management entry beside existing shop operations.
|
||||
|
||||
#### Scenario: reply to a review
|
||||
- **WHEN** a merchant opens an unreplied review of their shop and submits a reply
|
||||
- **THEN** the reply is stored once and the review row shows it as replied
|
||||
|
||||
#### Scenario: already replied review offers no second reply
|
||||
- **WHEN** a merchant opens a review that already carries their shop's reply
|
||||
- **THEN** no reply submission is offered and other shops' reviews are unreachable
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# reviews Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-product-reviews. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: One review per completed order line
|
||||
A signed-in customer SHALL create a review only for a product line of their own completed (received) order that has not been reviewed yet. A review SHALL carry a 1-5 star rating, text content, and optional image URLs, and the rating and content snapshot SHALL never change after creation. The database SHALL enforce at most one review per order line through a unique index on the order line, and creation SHALL validate the order-line precondition before insert.
|
||||
|
||||
#### Scenario: review a completed order line
|
||||
- **WHEN** a customer reviews a product line of their own completed order
|
||||
- **THEN** the review is created with the submitted rating, text, and image URLs and the line can no longer be reviewed
|
||||
|
||||
#### Scenario: second review of the same line is rejected
|
||||
- **WHEN** a customer submits a second review for an already reviewed order line
|
||||
- **THEN** the request is rejected and exactly one review row exists for that line
|
||||
|
||||
#### Scenario: unreviewable line is rejected
|
||||
- **WHEN** a customer reviews a line from another customer's order or from an order that is not completed
|
||||
- **THEN** the request is rejected without creating a review
|
||||
|
||||
### Requirement: Bilingual review content shape
|
||||
Review content and merchant replies SHALL be stored as `{en, zh}` localized JSONB content. A submission MAY populate only the shopper's or merchant's active locale, and every display SHALL fall back to the other locale when the active locale is empty.
|
||||
|
||||
#### Scenario: single-locale submission renders everywhere
|
||||
- **WHEN** a shopper submits review text in only one locale
|
||||
- **THEN** both mall locales display the review through the non-empty locale fallback
|
||||
|
||||
### Requirement: Single merchant reply per review
|
||||
Only a shop user of the review's own shop SHALL reply to a review, at most once. The reply SHALL be written with a guarded update that succeeds only while no reply exists, and a second or cross-shop reply attempt SHALL be rejected.
|
||||
|
||||
#### Scenario: first reply succeeds
|
||||
- **WHEN** a merchant of the reviewed product's shop replies to a review
|
||||
- **THEN** the reply is stored with its audit timestamp and appears with the review
|
||||
|
||||
#### Scenario: second reply is rejected
|
||||
- **WHEN** the same merchant submits another reply to a review that already has one
|
||||
- **THEN** the request is rejected and the existing reply is unchanged
|
||||
|
||||
### Requirement: Platform moderation hides or deletes reviews
|
||||
Platform admins SHALL hide or delete any review. Hiding SHALL be a soft delete recorded through a guarded status transition that validates the prior visible state, and deletion SHALL remove the row. Hidden and deleted reviews SHALL be absent from storefront listings and rating summaries, while admin listings SHALL still show hidden reviews with their state.
|
||||
|
||||
#### Scenario: hidden review leaves the storefront
|
||||
- **WHEN** a platform admin hides a visible review
|
||||
- **THEN** it disappears from the product's public review list and no longer contributes to the rating summary
|
||||
|
||||
#### Scenario: hide transition validates prior state
|
||||
- **WHEN** a platform admin hides a review that is already hidden
|
||||
- **THEN** the guarded transition changes nothing and reports the conflict
|
||||
|
||||
### Requirement: Visible-only paginated review listing
|
||||
A product's review list SHALL be publicly readable and paginated, containing only visible reviews with the reviewer's display name, rating, content, image URLs, creation time, and any merchant reply. Pagination totals SHALL count only visible reviews, and a customer's pending-review listing SHALL show only their own completed order lines without a review.
|
||||
|
||||
#### Scenario: totals count only visible reviews
|
||||
- **WHEN** a product has visible and hidden reviews and the public list is requested
|
||||
- **THEN** only visible reviews are returned and `total` excludes the hidden ones
|
||||
|
||||
#### Scenario: pending-review listing shrinks after submission
|
||||
- **WHEN** a customer reviews one of their pending order lines
|
||||
- **THEN** that line disappears from the pending-review listing
|
||||
|
||||
### Requirement: SQL rating summary aggregation
|
||||
The product rating summary SHALL be computed with SQL aggregation over visible reviews only and SHALL report the review count, average rating, and per-star (1-5) distribution. A product without visible reviews SHALL report a zero count, zero average, and an empty distribution.
|
||||
|
||||
#### Scenario: summary reflects only visible reviews
|
||||
- **WHEN** a product's summary is requested after one of its reviews is hidden
|
||||
- **THEN** the count, average, and star distribution exclude the hidden review
|
||||
|
||||
#### Scenario: product without reviews
|
||||
- **WHEN** the summary is requested for a product with no visible reviews
|
||||
- **THEN** it reports a zero count and zero average
|
||||
|
||||
@@ -45,6 +45,10 @@ import type {
|
||||
ProductStatus,
|
||||
PublicFlashSaleSession,
|
||||
RedeemPointsBody,
|
||||
Review,
|
||||
ReviewInput,
|
||||
ReviewSummary,
|
||||
ReviewableItem,
|
||||
FreightTemplate,
|
||||
FreightTemplateInput,
|
||||
Shipment,
|
||||
@@ -252,6 +256,10 @@ export interface ApiClient {
|
||||
reopenAftersale(id: string): Promise<Aftersale>;
|
||||
submitAftersaleReturnTracking(id: string, body: AftersaleReturnTrackingBody): Promise<Aftersale>;
|
||||
addAftersaleMessage(id: string, body: AftersaleMessageBody): Promise<AftersaleMessage>;
|
||||
listProductReviews(productId: string, page?: number): Promise<Paged<Review>>;
|
||||
getProductReviewSummary(productId: string): Promise<ReviewSummary>;
|
||||
listReviewableItems(): Promise<ReviewableItem[]>;
|
||||
createReview(body: ReviewInput): Promise<Review>;
|
||||
/** Public points catalog: published products only. */
|
||||
listPointsProducts(): Promise<IntegralProduct[]>;
|
||||
/** Server-computed per-shop shipping fees for the current cart + address. */
|
||||
@@ -317,6 +325,8 @@ export interface ApiClient {
|
||||
createFreightTemplate(body: FreightTemplateInput): Promise<FreightTemplate>;
|
||||
updateFreightTemplate(id: string, body: FreightTemplateInput): Promise<FreightTemplate>;
|
||||
deleteFreightTemplate(id: string): Promise<void>;
|
||||
listReviews(page?: number): Promise<Paged<Review>>;
|
||||
replyReview(id: string, content: LocalizedText): Promise<Review>;
|
||||
};
|
||||
admin: {
|
||||
listUsers(page?: number): Promise<Paged<User>>;
|
||||
@@ -338,6 +348,9 @@ export interface ApiClient {
|
||||
listAftersales(status?: AftersaleStatus): Promise<Aftersale[]>;
|
||||
getAftersale(id: string): Promise<AftersaleDetail>;
|
||||
arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise<Aftersale>;
|
||||
listReviews(page?: number): Promise<Paged<Review>>;
|
||||
hideReview(id: string): Promise<Review>;
|
||||
deleteReview(id: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,6 +417,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
submitAftersaleReturnTracking: (id, body) =>
|
||||
r("POST", `/aftersales/${id}/return-tracking`, body),
|
||||
addAftersaleMessage: (id, body) => r("POST", `/aftersales/${id}/messages`, body),
|
||||
listProductReviews: (productId, page = 1) =>
|
||||
r("GET", `/products/${productId}/reviews`, undefined, { page }),
|
||||
getProductReviewSummary: (productId) => r("GET", `/products/${productId}/review-summary`),
|
||||
listReviewableItems: () => r("GET", "/me/reviewable"),
|
||||
createReview: (body) => r("POST", "/reviews", body),
|
||||
listPointsProducts: () => r("GET", "/points/products"),
|
||||
listMyRedemptions: () => r("GET", "/points/redemptions"),
|
||||
redeemPoints: (body) => r("POST", "/points/redemptions", body),
|
||||
@@ -460,6 +478,8 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
createFreightTemplate: (body) => r("POST", "/shop/freight-templates", body),
|
||||
updateFreightTemplate: (id, body) => r("PUT", `/shop/freight-templates/${id}`, body),
|
||||
deleteFreightTemplate: (id) => r("DELETE", `/shop/freight-templates/${id}`),
|
||||
listReviews: (page = 1) => r("GET", "/shop/reviews", undefined, { page }),
|
||||
replyReview: (id, content) => r("POST", `/shop/reviews/${id}/reply`, { content }),
|
||||
},
|
||||
admin: {
|
||||
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
|
||||
@@ -485,6 +505,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
getAftersale: (id) => r("GET", `/admin/aftersales/${id}`),
|
||||
arbitrateAftersale: (id, outcome) =>
|
||||
r("POST", `/admin/aftersales/${id}/arbitrate`, { outcome }),
|
||||
listReviews: (page = 1) => r("GET", "/admin/reviews", undefined, { page }),
|
||||
hideReview: (id) => r("POST", `/admin/reviews/${id}/hide`),
|
||||
deleteReview: (id) => r("DELETE", `/admin/reviews/${id}`),
|
||||
listPointsProducts: () => r("GET", "/admin/points/products"),
|
||||
createPointsProduct: (body) => r("POST", "/admin/points/products", body),
|
||||
updatePointsProduct: (id, body) => r("PUT", `/admin/points/products/${id}`, body),
|
||||
|
||||
@@ -777,3 +777,50 @@ export interface ShippingQuote {
|
||||
shops: ShippingQuoteShop[];
|
||||
total_minor: number;
|
||||
}
|
||||
|
||||
// ---- reviews ----
|
||||
|
||||
export type ReviewStatus = "visible" | "hidden";
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
order_item_id: string;
|
||||
order_id: string;
|
||||
product_id: string;
|
||||
shop_id: string;
|
||||
user_id: string;
|
||||
rating: number;
|
||||
content: LocalizedText;
|
||||
images: string[];
|
||||
reply: LocalizedText | null;
|
||||
reply_at: string | null;
|
||||
status: ReviewStatus;
|
||||
created_at: string;
|
||||
reviewer_name: string;
|
||||
}
|
||||
|
||||
/** SQL-aggregated over visible reviews; distribution keys are "1".."5". */
|
||||
export interface ReviewSummary {
|
||||
count: number;
|
||||
avg_rating: number;
|
||||
distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
/** A completed order line of mine that has no review yet. */
|
||||
export interface ReviewableItem {
|
||||
order_item_id: string;
|
||||
order_id: string;
|
||||
order_no: string;
|
||||
product_id: string;
|
||||
product_name: LocalizedText;
|
||||
sku_code: string;
|
||||
image: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewInput {
|
||||
order_item_id: string;
|
||||
rating: number;
|
||||
content: LocalizedText;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user