本文档说明如何通过 GraphQL mutation 创建产品评价并上传图片到 AWS S3。
mutation CreateProductReview($input: CreateProductReviewInput!) {
createProductReview(input: $input) {
id
productId
title
comment
rating
name
status
createdAt
attachments
}
}
input CreateProductReviewInput {
productId: Int! # 产品ID(必填)
title: String! # 评价标题(必填)
comment: String! # 评价内容(必填)
rating: Int! # 评分 1-5(必填)
name: String! # 评价者姓名(必填)
email: String # 邮箱(可选)
status: Int # 状态:0=pending, 1=approved(可选,默认pending)
# 旧方式:Base64 JSON 字符串(向后兼容)
attachments: String
# 新方式:图片数组(推荐,支持S3上传)⭐
images: [ImageInput]
}
input ImageInput {
data: String! # Base64编码的图片数据
name: String # 文件名(可选)
type: String # MIME类型(可选)
}
这是新的推荐方式,会自动使用 ImageUploadService 上传到 S3。
mutation {
createProductReview(input: {
productId: 1
title: "Great Product!"
comment: "I love this product. Highly recommended!"
rating: 5
name: "John Doe"
images: [
"data:image/jpeg;base64,/9j/4AAQSkZJRg...",
"data:image/png;base64,iVBORw0KGgo..."
]
}) {
id
title
attachments
}
}
mutation {
createProductReview(input: {
productId: 1
title: "Amazing Quality"
comment: "The quality exceeded my expectations."
rating: 5
name: "Jane Smith"
images: [
{
data: "/9j/4AAQSkZJRg..." # 不带 data URI 前缀的纯 Base64
name: "product_front.jpg"
type: "image/jpeg"
},
{
data: "iVBORw0KGgo..."
name: "product_side.png"
type: "image/png"
}
]
}) {
id
title
attachments
}
}
旧的方式仍然支持,用于向后兼容。
mutation {
createProductReview(input: {
productId: 1
title: "Good Product"
comment: "Works as expected."
rating: 4
name: "Bob Wilson"
attachments: "[\"data:image/jpeg;base64,/9j/4AAQ...\"]"
}) {
id
title
attachments
}
}
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...
/9j/4AAQSkZJRgABAQEAYABgAAD...
使用纯 Base64 时,需要在对象中指定 type 字段:
{
"data": "/9j/4AAQSkZJRg...",
"name": "photo.jpg",
"type": "image/jpeg"
}
// 将文件转换为 Base64
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
// 创建评价并上传图片
async function createReviewWithImages(productId, reviewData, files) {
// 转换所有文件为 Base64
const images = await Promise.all(
files.map(async (file) => {
const base64 = await fileToBase64(file);
return {
data: base64,
name: file.name,
type: file.type
};
})
);
// GraphQL mutation
const mutation = `
mutation CreateProductReview($input: CreateProductReviewInput!) {
createProductReview(input: $input) {
id
productId
title
comment
rating
name
status
attachments
createdAt
}
}
`;
const variables = {
input: {
productId: productId,
title: reviewData.title,
comment: reviewData.comment,
rating: reviewData.rating,
name: reviewData.name,
email: reviewData.email,
images: images // 使用新的 images 字段
}
};
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN' // 如果需要认证
},
body: JSON.stringify({
query: mutation,
variables: variables
})
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
console.log('评价创建成功:', result.data.createProductReview);
return result.data.createProductReview;
} catch (error) {
console.error('创建评价失败:', error);
throw error;
}
}
// 使用示例
document.getElementById('reviewForm').addEventListener('submit', async (e) => {
e.preventDefault();
const files = Array.from(document.getElementById('images').files);
const reviewData = {
title: document.getElementById('title').value,
comment: document.getElementById('comment').value,
rating: parseInt(document.getElementById('rating').value),
name: document.getElementById('name').value,
email: document.getElementById('email').value
};
try {
const result = await createReviewWithImages(1, reviewData, files);
alert('评价提交成功!');
console.log('附件信息:', result.attachments);
} catch (error) {
alert('提交失败: ' + error.message);
}
});
<template>
<form @submit.prevent="submitReview">
<div>
<label>标题</label>
<input v-model="form.title" required />
</div>
<div>
<label>评价内容</label>
<textarea v-model="form.comment" required></textarea>
</div>
<div>
<label>评分</label>
<select v-model.number="form.rating">
<option v-for="n in 5" :key="n" :value="n">{{ n }} 星</option>
</select>
</div>
<div>
<label>上传图片</label>
<input
type="file"
ref="fileInput"
multiple
accept="image/*"
@change="handleFileSelect"
/>
</div>
<!-- 图片预览 -->
<div v-if="previews.length > 0" class="preview-grid">
<div v-for="(preview, index) in previews" :key="index">
<img :src="preview" style="max-width: 100px;" />
</div>
</div>
<button type="submit" :disabled="uploading">
{{ uploading ? '上传中...' : '提交评价' }}
</button>
</form>
</template>
<script>
export default {
props: {
productId: {
type: Number,
required: true
}
},
data() {
return {
form: {
title: '',
comment: '',
rating: 5,
name: '',
email: ''
},
selectedFiles: [],
previews: [],
uploading: false
};
},
methods: {
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.selectedFiles = files;
// 生成预览
this.previews = files.map(file => URL.createObjectURL(file));
},
async fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
},
async submitReview() {
this.uploading = true;
try {
// 转换文件为 Base64
const images = await Promise.all(
this.selectedFiles.map(async (file) => ({
data: await this.fileToBase64(file),
name: file.name,
type: file.type
}))
);
// 调用 GraphQL
const response = await this.$apollo.mutate({
mutation: gql`
mutation CreateProductReview($input: CreateProductReviewInput!) {
createProductReview(input: $input) {
id
title
attachments
}
}
`,
variables: {
input: {
productId: this.productId,
...this.form,
images: images
}
}
});
this.$toast.success('评价提交成功!');
this.resetForm();
} catch (error) {
this.$toast.error('提交失败: ' + error.message);
} finally {
this.uploading = false;
}
},
resetForm() {
this.form = {
title: '',
comment: '',
rating: 5,
name: '',
email: ''
};
this.selectedFiles = [];
this.previews = [];
this.$refs.fileInput.value = '';
}
}
};
</script>
import React, { useState } from 'react';
import { useMutation, gql } from '@apollo/client';
const CREATE_REVIEW_MUTATION = gql`
mutation CreateProductReview($input: CreateProductReviewInput!) {
createProductReview(input: $input) {
id
title
comment
rating
attachments
}
}
`;
function ReviewForm({ productId }) {
const [createReview] = useMutation(CREATE_REVIEW_MUTATION);
const [form, setForm] = useState({
title: '',
comment: '',
rating: 5,
name: '',
email: ''
});
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setUploading(true);
try {
// 转换文件
const images = await Promise.all(
files.map(async (file) => ({
data: await fileToBase64(file),
name: file.name,
type: file.type
}))
);
// 提交 mutation
const { data } = await createReview({
variables: {
input: {
productId,
...form,
images
}
}
});
alert('评价提交成功!');
console.log('附件:', data.createProductReview.attachments);
// 重置表单
setForm({ title: '', comment: '', rating: 5, name: '', email: '' });
setFiles([]);
} catch (error) {
alert('提交失败: ' + error.message);
} finally {
setUploading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="标题"
value={form.title}
onChange={(e) => setForm({...form, title: e.target.value})}
required
/>
<textarea
placeholder="评价内容"
value={form.comment}
onChange={(e) => setForm({...form, comment: e.target.value})}
required
/>
<select
value={form.rating}
onChange={(e) => setForm({...form, rating: parseInt(e.target.value)})}
>
{[1, 2, 3, 4, 5].map(n => (
<option key={n} value={n}>{n} 星</option>
))}
</select>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => setFiles(Array.from(e.target.files))}
/>
<button type="submit" disabled={uploading}>
{uploading ? '上传中...' : '提交评价'}
</button>
</form>
);
}
export default ReviewForm;
成功的响应会包含上传后的图片信息:
{
"data": {
"createProductReview": {
"id": 123,
"productId": 1,
"title": "Great Product!",
"comment": "I love this product!",
"rating": 5,
"name": "John Doe",
"status": 0,
"attachments": "[{\"type\":\"image\",\"url\":\"https://bucket.s3.region.amazonaws.com/review/123/abc123.webp\",\"path\":\"review/123/abc123.webp\"}]",
"createdAt": "2026-05-27 10:30:00"
}
}
}
解析 attachments 字段:
const attachments = JSON.parse(response.attachments);
// [
// {
// "type": "image",
// "url": "https://bucket.s3.region.amazonaws.com/review/123/abc123.webp",
// "path": "review/123/abc123.webp"
// }
// ]
图片大小超限
{
"errors": [{
"message": "File size exceeds the 5MB limit"
}]
}
无效的图片格式
{
"errors": [{
"message": "Invalid file format"
}]
}
Base64 解码失败
{
"errors": [{
"message": "Invalid base64 encoding"
}]
}
确保 .env 文件中正确配置了 S3:
IMAGE_UPLOAD_DISK=s3
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_URL=https://your-bucket.s3.us-east-1.amazonaws.com
需要帮助? 查看 ImageUpload 模块文档