基于Vue与Spring Boot的摄影作品分享与评分系统 - 源码深度解析

JavaJavaScriptMavenHTMLCSSSSM框架MySQLSpringboot框架使用Vue
2026-02-205 浏览

文章摘要

本项目是一款基于Vue与Spring Boot技术栈构建的摄影作品分享与评分系统,旨在为摄影爱好者打造一个专业、互动性强的线上社区。其核心业务价值在于解决了摄影师缺乏专业反馈渠道、作品展示形式单一以及社区互动不足的痛点。系统通过结构化的分享与评分机制,将个人创作与集体鉴赏相结合,不仅帮助用户展示个人...

在当今数字化内容创作蓬勃发展的时代,摄影爱好者群体日益壮大,他们不仅需要展示作品的平台,更渴望获得专业、有价值的反馈以精进技艺。传统的社交媒体平台虽然提供了展示空间,但其评论系统往往流于表面,缺乏结构化的评价体系。一个专注于摄影作品分享与深度互动的专业社区平台应运而生,它采用现代Web技术栈构建,旨在解决摄影师在创作成长路径中的核心痛点。

该系统采用前后端分离的架构模式,前端基于Vue.js生态体系,后端则构建于成熟的Spring Boot框架之上。Vue.js的响应式数据和组件化开发模式为构建动态、交互丰富的用户界面提供了强大支持。通过Vue Router实现单页面应用的路由管理,Vuex进行状态管理,确保了前端应用的可维护性和数据流清晰度。后端API遵循RESTful设计原则,使用Spring Boot快速搭建微服务架构,Spring Security负责身份认证与授权,Spring Data JPA简化了数据持久层操作,与MySQL数据库进行高效交互。这种架构分离了关注点,使得前后端开发可以并行进行,也便于后续的独立部署和扩展。

数据库架构设计与核心模型分析

系统共设计了22张数据表,构建了完整的业务数据模型。其中,photography_works(摄影作品表)、user(用户表)和rating(评分记录表)是支撑核心业务逻辑的关键实体。

摄影作品表 (photography_works) 设计

CREATE TABLE `photography_works` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `title` varchar(255) NOT NULL COMMENT '作品标题',
  `description` text COMMENT '作品描述',
  `image_url` varchar(500) NOT NULL COMMENT '图片存储路径',
  `thumbnail_url` varchar(500) DEFAULT NULL COMMENT '缩略图路径',
  `category_id` bigint(20) NOT NULL COMMENT '分类ID',
  `user_id` bigint(20) NOT NULL COMMENT '上传用户ID',
  `exif_data` json DEFAULT NULL COMMENT 'EXIF信息(相机型号、光圈、快门等)',
  `view_count` int(11) DEFAULT '0' COMMENT '浏览量',
  `average_rating` decimal(3,2) DEFAULT '0.00' COMMENT '平均评分',
  `rating_count` int(11) DEFAULT '0' COMMENT '评分人数',
  `status` tinyint(4) DEFAULT '1' COMMENT '状态(1-正常,0-下架)',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_category_id` (`category_id`),
  KEY `idx_create_time` (`create_time`),
  CONSTRAINT `fk_works_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
  CONSTRAINT `fk_works_category` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='摄影作品表';

该表设计体现了对摄影作品专业属性的充分考虑。exif_data字段采用JSON类型,灵活地存储了照片的元数据信息,如相机型号、光圈、快门速度、ISO等,为技术型评鉴提供了数据基础。average_ratingrating_count字段的冗余设计,避免了每次查询平均分时都需要聚合rating表,显著提升了作品列表页的查询性能。通过view_count统计浏览量,为热度排序提供了依据。多个索引的建立优化了按用户、分类和时间的查询效率。

评分记录表 (rating) 设计

CREATE TABLE `rating` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `work_id` bigint(20) NOT NULL COMMENT '作品ID',
  `user_id` bigint(20) NOT NULL COMMENT '评分用户ID',
  `score` tinyint(1) NOT NULL COMMENT '评分分数(1-5)',
  `comment` text COMMENT '评分评论',
  `dimension_1` tinyint(1) DEFAULT NULL COMMENT '维度1:构图',
  `dimension_2` tinyint(1) DEFAULT NULL COMMENT '维度2:用光',
  `dimension_3` tinyint(1) DEFAULT NULL COMMENT '维度3:色彩',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_work_user` (`work_id`,`user_id`),
  KEY `idx_work_id` (`work_id`),
  KEY `idx_user_id` (`user_id`),
  CONSTRAINT `fk_rating_work` FOREIGN KEY (`work_id`) REFERENCES `photography_works` (`id`),
  CONSTRAINT `fk_rating_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作品评分表';

此表设计是系统评分机制的核心。UNIQUE KEY uk_work_user唯一索引确保了同一用户对同一作品只能评分一次,维护了评分的公平性。最具特色的是引入了多维度评分字段dimension_1dimension_3,将主观的艺术评价进行了结构化分解,引导用户从“构图”、“用光”、“色彩”等专业角度进行点评,使反馈更具指导意义。这种设计超越了简单的五星评分,为摄影师提供了更精细化的改进方向。

核心功能模块深度解析

1. 智能作品上传与元数据提取

作品上传不仅是文件传输,更是作品信息结构化录入的过程。前端通过自定义Vue组件封装了拖拽上传、进度显示和预览功能。

前端上传组件关键代码 (Vue.js)

<template>
  <div class="upload-container">
    <el-upload
      action="/api/upload"
      drag
      :multiple="false"
      :before-upload="beforeUpload"
      :on-success="handleSuccess"
      :on-error="handleError"
      accept=".jpg,.jpeg,.png,.heic">
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">将作品拖到此处,或<em>点击上传</em></div>
    </el-upload>
    <div v-if="exifData" class="exif-panel">
      <h4>EXIF信息</h4>
      <p>相机: {{ exifData.Make }} {{ exifData.Model }}</p>
      <p>焦距: {{ exifData.FocalLength }}mm</p>
      <p>光圈: f/{{ exifData.FNumber }}</p>
      <p>快门: {{ exifData.ExposureTime }}s</p>
      <p>ISO: {{ exifData.ISO }}</p>
    </div>
  </div>
</template>

<script>
import { extractEXIF } from '@/utils/exif-utils';

export default {
  data() {
    return {
      exifData: null
    };
  },
  methods: {
    beforeUpload(file) {
      return new Promise((resolve) => {
        extractEXIF(file).then(data => {
          this.exifData = data;
          resolve(true);
        }).catch(() => {
          this.exifData = null;
          resolve(true);
        });
      });
    },
    handleSuccess(response) {
      this.$emit('upload-success', {
        imageUrl: response.data.url,
        exifData: this.exifData
      });
    }
  }
};
</script>

后端通过Apache Sanselan库解析上传图片的EXIF数据,自动提取摄影参数,减轻用户手动输入的负担。

后端EXIF解析服务 (Java)

@Service
public class ExifService {
    
    public PhotoExifDTO extractExifData(MultipartFile imageFile) {
        try {
            IImageMetadata metadata = Sanselan.getMetadata(imageFile.getBytes());
            if (metadata instanceof JpegImageMetadata) {
                JpegImageMetadata jpegMetadata = (JpegImageMetadata) metadata;
                PhotoExifDTO exifData = new PhotoExifDTO();
                
                exifData.setMake(getTagValue(jpegMetadata, TiffConstants.TIFF_TAG_MAKE));
                exifData.setModel(getTagValue(jpegMetadata, TiffConstants.TIFF_TAG_MODEL));
                exifData.setFocalLength(getTagValue(jpegMetadata, TiffConstants.EXIF_TAG_FOCAL_LENGTH));
                exifData.setFNumber(getTagValue(jpegMetadata, TiffConstants.EXIF_TAG_FNUMBER));
                exifData.setExposureTime(getTagValue(jpegMetadata, TiffConstants.EXIF_TAG_EXPOSURE_TIME));
                exifData.setISO(getTagValue(jpegMetadata, TiffConstants.EXIF_TAG_ISO));
                
                return exifData;
            }
        } catch (Exception e) {
            log.warn("EXIF数据提取失败: {}", e.getMessage());
        }
        return null;
    }
    
    private String getTagValue(JpegImageMetadata metadata, int tagType) {
        TiffField field = metadata.findEXIFValueWithExactMatch(tagType);
        return field != null ? field.getValueDescription() : null;
    }
}

作品上传界面

2. 多维度评分与动态展示系统

评分功能是系统的灵魂,通过前后端协同实现实时、准确的评分计算和展示。

前端评分组件 (Vue.js)

<template>
  <div class="rating-widget">
    <div class="overall-rating">
      <span class="average">{{ work.averageRating }}</span>
      <el-rate v-model="userRating" :max="5" @change="handleRate" />
      <span class="count">({{ work.ratingCount }}人评价)</span>
    </div>
    
    <div v-if="showDetails" class="dimension-rating">
      <div class="dimension">
        <label>构图:</label>
        <el-rate v-model="dimensionRating.composition" show-text 
                 :texts="['较差', '一般', '良好', '很好', '完美']" />
      </div>
      <div class="dimension">
        <label>用光:</label>
        <el-rate v-model="dimensionRating.lighting" show-text 
                 :texts="['较差', '一般', '良好', '很好', '完美']" />
      </div>
      <div class="dimension">
        <label>色彩:</label>
        <el-rate v-model="dimensionRating.color" show-text 
                 :texts="['较差', '一般', '良好', '很好', '完美']" />
      </div>
    </div>
    
    <el-button type="text" @click="showDetails = !showDetails">
      {{ showDetails ? '简化评分' : '详细评分' }}
    </el-button>
  </div>
</template>

<script>
export default {
  props: ['work'],
  data() {
    return {
      userRating: 0,
      showDetails: false,
      dimensionRating: {
        composition: 0,
        lighting: 0,
        color: 0
      }
    };
  },
  methods: {
    async handleRate(score) {
      try {
        const response = await this.$http.post('/api/ratings', {
          workId: this.work.id,
          score: score,
          dimensions: this.dimensionRating
        });
        
        this.$emit('rating-updated', response.data);
        this.$message.success('评分成功!');
      } catch (error) {
        this.$message.error('评分失败');
      }
    }
  }
};
</script>

后端评分处理逻辑 (Spring Boot)

@RestController
@RequestMapping("/api/ratings")
public class RatingController {
    
    @Autowired
    private RatingService ratingService;
    
    @PostMapping
    public ResponseEntity<RatingResultDTO> rateWork(@RequestBody RatingRequest request) {
        // 检查用户是否已评分
        if (ratingService.hasRated(request.getWorkId(), getCurrentUserId())) {
            throw new BusinessException("您已经对该作品评过分了");
        }
        
        RatingResultDTO result = ratingService.addRating(request);
        return ResponseEntity.ok(result);
    }
}

@Service
@Transactional
public class RatingService {
    
    @Autowired
    private RatingRepository ratingRepository;
    
    @Autowired
    private PhotographyWorksRepository worksRepository;
    
    public RatingResultDTO addRating(RatingRequest request) {
        // 保存评分记录
        Rating rating = new Rating();
        rating.setWorkId(request.getWorkId());
        rating.setUserId(request.getUserId());
        rating.setScore(request.getScore());
        rating.setDimension1(request.getDimensions().getComposition());
        rating.setDimension2(request.getDimensions().getLighting());
        rating.setDimension3(request.getDimensions().getColor());
        rating.setComment(request.getComment());
        ratingRepository.save(rating);
        
        // 更新作品评分统计
        PhotographyWorks work = worksRepository.findById(request.getWorkId())
            .orElseThrow(() -> new ResourceNotFoundException("作品不存在"));
        
        long totalRatings = ratingRepository.countByWorkId(request.getWorkId());
        double newAverage = ratingRepository.calculateAverageRating(request.getWorkId());
        
        work.setRatingCount(totalRatings);
        work.setAverageRating(BigDecimal.valueOf(newAverage));
        worksRepository.save(work);
        
        // 构建返回结果
        return RatingResultDTO.builder()
            .workId(request.getWorkId())
            .newAverage(newAverage)
            .totalRatings(totalRatings)
            .userRating(request.getScore())
            .build();
    }
    
    public boolean hasRated(Long workId, Long userId) {
        return ratingRepository.existsByWorkIdAndUserId(workId, userId);
    }
}

作品详情与评分界面

3. 画廊式作品展示与智能筛选

系统采用响应式画廊布局展示作品,支持多种筛选和排序方式,提升用户浏览体验。

前端画廊组件 (Vue.js)

<template>
  <div class="gallery-container">
    <div class="filters">
      <el-select v-model="filters.category" placeholder="作品分类" clearable>
        <el-option v-for="cat in categories" :key="cat.id" 
                   :label="cat.name" :value="cat.id" />
      </el-select>
      
      <el-select v-model="filters.sortBy" placeholder="排序方式">
        <el-option label="最新发布" value="create_time" />
        <el-option label="评分最高" value="average_rating" />
        <el-option label="最受欢迎" value="view_count" />
      </el-select>
      
      <el-input v-model="filters.keyword" placeholder="搜索作品标题" 
                prefix-icon="el-icon-search" clearable />
    </div>
    
    <div class="masonry-grid">
      <div v-for="work in works" :key="work.id" class="grid-item">
        <work-card :work="work" @click="viewDetail(work)" />
      </div>
    </div>
    
    <div class="pagination">
      <el-pagination
        :current-page="pagination.current"
        :page-size="pagination.size"
        :total="pagination.total"
        @current-change="handlePageChange"
        layout="total, prev, pager, next, jumper" />
    </div>
  </div>
</template>

<script>
import WorkCard from '@/components/WorkCard.vue';

export default {
  components: { WorkCard },
  data() {
    return {
      works: [],
      categories: [],
      filters: {
        category: null,
        sortBy: 'create_time',
        keyword: ''
      },
      pagination: {
        current: 1,
        size: 12,
        total: 0
      }
    };
  },
  watch: {
    filters: {
      handler: 'loadWorks',
      deep: true
    }
  },
  methods: {
    async loadWorks() {
      const params = {
        page: this.pagination.current - 1,
        size: this.pagination.size,
        ...this.filters
      };
      
      const response = await this.$http.get('/api/works', { params });
      this.works = response.data.content;
      this.pagination.total = response.data.totalElements;
    },
    
    handlePageChange(page) {
      this.pagination.current = page;
      this.loadWorks();
    }
  }
};
</script>

后端作品查询接口 (Spring Data JPA)

@Repository
public interface PhotographyWorksRepository extends JpaRepository<PhotographyWorks, Long>,
                                                    JpaSpecification<PhotographyWorks> {
    
    @Query("SELECT w FROM PhotographyWorks w WHERE w.status = 1 " +
           "AND (:categoryId IS NULL OR w.categoryId = :categoryId) " +
           "AND (:keyword IS NULL OR w.title LIKE %:keyword%) " +
           "ORDER BY " +
           "CASE WHEN :sortBy = 'create_time' THEN w.createTime END DESC, " +
           "CASE WHEN :sortBy = 'average_rating' THEN w.averageRating END DESC, " +
           "CASE WHEN :sortBy = 'view_count' THEN w.viewCount END DESC")
    Page<PhotographyWorks> findWorksWithFilters(@Param("categoryId") Long categoryId,
                                               @Param("keyword") String keyword,
                                               @Param("sortBy") String sortBy,
                                               Pageable pageable);
}

@RestController
@RequestMapping("/api/works")
public class WorksController {
    
    @GetMapping
    public Page<WorkSummaryDTO> getWorks(
            @RequestParam(required = false) Long categoryId,
            @RequestParam(required = false) String keyword,
            @RequestParam(defaultValue = "create_time") String sortBy,
            @PageableDefault(size = 12) Pageable pageable) {
        
        Page<PhotographyWorks> worksPage = worksRepository
            .findWorksWithFilters(categoryId, keyword, sortBy, pageable);
            
        return worksPage.map(work -> WorkSummaryDTO.builder()
            .id(work.getId())
            .title(work.getTitle())
            .thumbnailUrl(work.getThumbnailUrl())
            .authorName(work.getUser().getNickname())
            .
本文关键词
VueSpring Boot摄影作品分享系统评分系统

上下篇

上一篇
没有更多文章
下一篇
没有更多文章