大家好,我是Java1234_小锋老师,分享一套锋哥原创的基于BERT的电商商品评论情感分析系统(PyTorch+FastAPI+Vue3)。

项目介绍

随着电子商务规模持续扩大,商品评论已成为消费者决策与商家改进产品的重要依据。海量评论文本具有口语化、领域词汇密集、正负情感交织等特点,传统基于词典或浅层机器学习的情感分析方法难以充分刻画上下文语义,分类精度受到限制。针对上述问题,本文设计并实现了一套基于 BERT 的电商商品评论情感分析系统,完成从评论采集、模型推理、结果存储到可视化分析的闭环。

系统采用前后端分离架构。后端以 Python 语言和 FastAPI 框架构建 RESTful 接口,使用 SQLAlchemy 访问 MySQL 8 数据库 db_bert_sentiment,核心推理模块基于 PyTorch 与 Transformers 加载中文 BERT 微调模型 BertForSequenceClassification,对评论进行 1 至 5 星五分类,并映射为正面、中性、负面三类情感;当微调模型文件缺失时自动回退到电商情感词典规则引擎,保证系统可用性。前端采用 Vue3、Vite、Element Plus、Pinia 与 ECharts 实现管理端界面,支持单条实时分析、CSV 批量导入、评论维护、统计分析、模型管理、个人中心和操作日志等功能。

在数据库设计方面,系统围绕管理员、评论、分析任务、模型信息、情感关键词和操作日志六类实体建立概念模型,给出独立的实体属性图与实体间关系图,并以表格形式详细列出各表字段名称、类型、长度、是否为空及备注。测试表明,系统能够稳定完成登录鉴权、情感推理、批量任务与多维图表展示,BERT 引擎在样例数据上可给出星级、情感倾向与置信度,满足本科毕业设计对完整性、规范性和可演示性的要求。

源码下载

链接: https://pan.baidu.com/s/1K8tn-7FncuNY870mTHgd0A?pwd=1234
提取码: 1234

系统展示

核心代码

# -*- coding: utf-8 -*-
"""个人中心:资料修改、密码修改、头像上传。"""
import os
import uuid
from datetime import datetime

from fastapi import APIRouter, Depends, File, Request, UploadFile
from sqlalchemy.orm import Session

from config import settings
from database import get_db
from schemas import PasswordUpdateIn, ProfileUpdateIn
from utils.deps import get_current_admin, write_log
from utils.response import fail, success
from utils.serialize import admin_dict

router = APIRouter(prefix="/api/profile", tags=["个人中心"])


@router.get("")
def get_profile(admin=Depends(get_current_admin)):
    """查询当前管理员个人资料。"""
    return success(admin_dict(admin))


@router.put("")
def update_profile(
    body: ProfileUpdateIn,
    request: Request,
    admin=Depends(get_current_admin),
    db: Session = Depends(get_db),
):
    """修改昵称、手机、邮箱、性别。"""
    if body.gender not in ("男", "女"):
        return fail("性别只能为男或女")
    admin.nickname = body.nickname
    admin.phone = body.phone
    admin.email = body.email
    admin.gender = body.gender
    admin.update_time = datetime.now()
    db.add(admin)
    db.commit()
    db.refresh(admin)
    write_log(db, admin, "修改个人资料", "PUT", request)
    return success(admin_dict(admin), "资料已更新")


@router.put("/password")
def update_password(
    body: PasswordUpdateIn,
    request: Request,
    admin=Depends(get_current_admin),
    db: Session = Depends(get_db),
):
    """修改登录密码,明文存储。"""
    if admin.password != body.old_password:
        return fail("原密码不正确")
    if body.new_password != body.confirm_password:
        return fail("两次输入的新密码不一致")
    if not body.new_password:
        return fail("新密码不能为空")
    admin.password = body.new_password
    admin.update_time = datetime.now()
    db.add(admin)
    db.commit()
    write_log(db, admin, "修改登录密码", "PUT", request)
    return success(None, "密码已修改")


@router.post("/avatar")
def upload_avatar(
    request: Request,
    file: UploadFile = File(...),
    admin=Depends(get_current_admin),
    db: Session = Depends(get_db),
):
    """上传管理员头像,保存到 D:/uploads47/avatar。"""
    suffix = os.path.splitext(file.filename or "")[1].lower()
    if suffix not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
        return fail("仅支持 jpg/png/gif/webp 图片")
    os.makedirs(settings.AVATAR_DIR, exist_ok=True)
    name = f"{admin.id}_{uuid.uuid4().hex}{suffix}"
    save_path = os.path.join(settings.AVATAR_DIR, name)
    with open(save_path, "wb") as f:
        f.write(file.file.read())
    rel = f"/uploads/avatar/{name}"
    admin.avatar = rel
    admin.update_time = datetime.now()
    db.add(admin)
    db.commit()
    db.refresh(admin)
    write_log(db, admin, "上传头像", "POST", request)
    return success({"avatar": rel, "admin": admin_dict(admin)}, "头像已更新")
<template>
  <div class="profile-page">
    <!-- 顶部个人信息横幅 -->
    <div class="profile-banner">
      <div class="banner-bg"></div>
      <div class="banner-content">
        <div class="banner-avatar-wrap">
          <el-avatar :size="72" :src="form.avatar" class="banner-avatar">
            {{ (form.nickname || '管').slice(0, 1) }}
          </el-avatar>
        </div>
        <div class="banner-info">
          <h2 class="banner-name">{{ form.nickname || '管理员' }}</h2>
          <p class="banner-meta">
            <span><el-icon><User /></el-icon>{{ form.username }}</span>
            <span><el-icon><Male v-if="form.gender === '男'" /><Female v-else /></el-icon>{{ form.gender }}</span>
          </p>
        </div>
        <div class="banner-time" v-if="form.create_time">
          <span class="time-label">注册时间</span>
          <span class="time-value">{{ formatDateTime(form.create_time) }}</span>
        </div>
      </div>
    </div>

    <!-- 主体:左右布局 -->
    <div class="profile-body">
      <!-- 左侧:头像上传 -->
      <div class="profile-sidebar">
        <div class="sidebar-card">
          <div class="avatar-section">
            <div class="avatar-ring">
              <el-avatar :size="112" :src="form.avatar" class="main-avatar">
                {{ (form.nickname || '管').slice(0, 1) }}
              </el-avatar>
              <el-upload
                class="avatar-uploader"
                :show-file-list="false"
                accept=".jpg,.jpeg,.png,.gif,.webp"
                :http-request="doUpload"
              >
                <div class="avatar-overlay">
                  <el-icon :size="28"><Camera /></el-icon>
                  <span>更换头像</span>
                </div>
              </el-upload>
            </div>
            <p class="avatar-name">{{ form.nickname || '管理员' }}</p>
            <p class="avatar-role">系统管理员</p>
          </div>
          <el-divider />
          <ul class="info-list">
            <li>
              <el-icon><Iphone /></el-icon>
              <span>{{ form.phone || '未填写手机号' }}</span>
            </li>
            <li>
              <el-icon><Message /></el-icon>
              <span>{{ form.email || '未填写邮箱' }}</span>
            </li>
          </ul>
          <p class="upload-hint">支持 JPG、PNG、GIF、WebP,大小不超过 2MB</p>
        </div>
      </div>

      <!-- 右侧:资料与密码 -->
      <div class="profile-content">
        <el-card shadow="hover" class="content-card">
          <el-tabs v-model="activeTab" class="profile-tabs">
            <el-tab-pane label="基本资料" name="info">
              <div class="tab-panel">
                <div class="section-head">
                  <el-icon class="section-icon"><EditPen /></el-icon>
                  <div>
                    <h3>个人资料修改</h3>
                    <p>更新您的昵称、联系方式等基本信息</p>
                  </div>
                </div>
                <el-form
                  ref="profileFormRef"
                  :model="form"
                  :rules="profileRules"
                  label-width="88px"
                  class="profile-form"
                >
                  <el-form-item label="用户名" prop="username">
                    <el-input :model-value="form.username" disabled>
                      <template #prefix><el-icon><User /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="昵称" prop="nickname">
                    <el-input v-model="form.nickname" placeholder="请输入昵称" clearable>
                      <template #prefix><el-icon><Avatar /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="手机号" prop="phone">
                    <el-input v-model="form.phone" placeholder="请输入手机号" clearable maxlength="11">
                      <template #prefix><el-icon><Iphone /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="邮箱" prop="email">
                    <el-input v-model="form.email" placeholder="请输入邮箱" clearable>
                      <template #prefix><el-icon><Message /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="性别" prop="gender">
                    <el-radio-group v-model="form.gender" class="gender-group">
                      <el-radio-button label="男">
                        <el-icon><Male /></el-icon> 男
                      </el-radio-button>
                      <el-radio-button label="女">
                        <el-icon><Female /></el-icon> 女
                      </el-radio-button>
                    </el-radio-group>
                  </el-form-item>
                  <el-form-item>
                    <el-button type="primary" :loading="profileSaving" @click="saveProfile">
                      <el-icon><Check /></el-icon> 保存资料
                    </el-button>
                    <el-button @click="load">重置</el-button>
                  </el-form-item>
                </el-form>
              </div>
            </el-tab-pane>

            <el-tab-pane label="安全设置" name="security">
              <div class="tab-panel">
                <div class="section-head">
                  <el-icon class="section-icon security"><Lock /></el-icon>
                  <div>
                    <h3>密码修改</h3>
                    <p>定期更换密码,保障账号安全</p>
                  </div>
                </div>
                <el-alert
                  title="密码长度建议 6 位以上,请勿与旧密码相同"
                  type="info"
                  :closable="false"
                  show-icon
                  class="pwd-alert"
                />
                <el-form
                  ref="pwdFormRef"
                  :model="pwd"
                  :rules="pwdRules"
                  label-width="88px"
                  class="profile-form"
                >
                  <el-form-item label="原密码" prop="old_password">
                    <el-input
                      v-model="pwd.old_password"
                      type="password"
                      show-password
                      placeholder="请输入原密码"
                    >
                      <template #prefix><el-icon><Key /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="新密码" prop="new_password">
                    <el-input
                      v-model="pwd.new_password"
                      type="password"
                      show-password
                      placeholder="请输入新密码"
                    >
                      <template #prefix><el-icon><Lock /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item label="确认密码" prop="confirm_password">
                    <el-input
                      v-model="pwd.confirm_password"
                      type="password"
                      show-password
                      placeholder="请再次输入新密码"
                    >
                      <template #prefix><el-icon><CircleCheck /></el-icon></template>
                    </el-input>
                  </el-form-item>
                  <el-form-item>
                    <el-button type="warning" :loading="pwdSaving" @click="savePwd">
                      <el-icon><Refresh /></el-icon> 修改密码
                    </el-button>
                  </el-form-item>
                </el-form>
              </div>
            </el-tab-pane>
          </el-tabs>
        </el-card>
      </div>
    </div>
  </div>
</template>

<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getProfile, updateProfile, updatePassword, uploadAvatar } from '../../api/profile'
import { useUserStore } from '../../stores/user'
import { avatarUrl, formatDateTime } from '../../utils/format'

const store = useUserStore()
const activeTab = ref('info')
const profileFormRef = ref()
const pwdFormRef = ref()
const profileSaving = ref(false)
const pwdSaving = ref(false)

const form = reactive({
  username: '',
  nickname: '',
  phone: '',
  email: '',
  gender: '男',
  avatar: '',
  create_time: '',
})

const pwd = reactive({ old_password: '', new_password: '', confirm_password: '' })

/** 确认密码校验 */
function validateConfirm(rule, value, callback) {
  if (value !== pwd.new_password) {
    callback(new Error('两次输入的新密码不一致'))
  } else {
    callback()
  }
}

const profileRules = {
  nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
  phone: [{ pattern: /^1[3-9]\d{9}$|^$/, message: '手机号格式不正确', trigger: 'blur' }],
  email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
}

const pwdRules = {
  old_password: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
  new_password: [
    { required: true, message: '请输入新密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' },
  ],
  confirm_password: [
    { required: true, message: '请确认新密码', trigger: 'blur' },
    { validator: validateConfirm, trigger: 'blur' },
  ],
}

/** 加载个人资料 */
async function load() {
  const res = await getProfile()
  Object.assign(form, res.data)
  form.avatar = avatarUrl(res.data.avatar)
}

/** 保存资料 */
async function saveProfile() {
  const valid = await profileFormRef.value?.validate().catch(() => false)
  if (!valid) return
  profileSaving.value = true
  try {
    const res = await updateProfile({
      nickname: form.nickname,
      phone: form.phone,
      email: form.email,
      gender: form.gender,
    })
    store.setAdmin(res.data)
    ElMessage.success('资料已更新')
  } finally {
    profileSaving.value = false
  }
}

/** 修改密码 */
async function savePwd() {
  const valid = await pwdFormRef.value?.validate().catch(() => false)
  if (!valid) return
  pwdSaving.value = true
  try {
    await updatePassword(pwd)
    ElMessage.success('密码已修改,请妥善保管')
    pwdFormRef.value?.resetFields()
    Object.assign(pwd, { old_password: '', new_password: '', confirm_password: '' })
  } finally {
    pwdSaving.value = false
  }
}

/** 上传头像 */
async function doUpload(option) {
  const file = option.file
  if (file.size > 2 * 1024 * 1024) {
    ElMessage.warning('图片大小不能超过 2MB')
    return
  }
  const res = await uploadAvatar(file)
  form.avatar = avatarUrl(res.data.avatar)
  store.setAdmin(res.data.admin)
  ElMessage.success('头像已更新')
}

onMounted(load)
</script>

<style scoped>
.profile-page {
  width: 100%;
  min-height: calc(100vh - 60px - 32px);
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
}

/* 顶部横幅 */
.profile-banner {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  margin-bottom: 20px;
  box-shadow: 0 4px 20px rgba(99, 102, 241, 0.15);
}

.banner-bg {
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #0ea5e9 100%);
}

.banner-content {
  position: relative;
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 28px 32px;
  color: #fff;
}

.banner-avatar-wrap {
  flex-shrink: 0;
}

.banner-avatar {
  border: 3px solid rgba(255, 255, 255, 0.85);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  background: rgba(255, 255, 255, 0.2);
}

.banner-info {
  flex: 1;
  min-width: 0;
}

.banner-name {
  margin: 0 0 8px;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: 0.5px;
}

.banner-meta {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  font-size: 14px;
  opacity: 0.92;
}

.banner-meta span {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.banner-time {
  flex-shrink: 0;
  text-align: right;
  padding: 10px 16px;
  background: rgba(255, 255, 255, 0.15);
  border-radius: 10px;
  backdrop-filter: blur(4px);
}

.time-label {
  display: block;
  font-size: 12px;
  opacity: 0.85;
  margin-bottom: 4px;
}

.time-value {
  font-size: 14px;
  font-weight: 500;
}

/* 主体布局 */
.profile-body {
  display: flex;
  flex: 1;
  gap: 20px;
  align-items: stretch;
}

.profile-sidebar {
  width: 280px;
  flex-shrink: 0;
}

.sidebar-card {
  background: #fff;
  border-radius: 12px;
  padding: 28px 24px;
  box-shadow: 0 2px 12px rgba(15, 28, 63, 0.06);
  transition: box-shadow 0.25s;
  height: 100%;
  box-sizing: border-box;
}

.sidebar-card:hover {
  box-shadow: 0 6px 24px rgba(15, 28, 63, 0.1);
}

.avatar-section {
  text-align: center;
}

.avatar-ring {
  position: relative;
  display: inline-block;
  padding: 4px;
  border-radius: 50%;
  background: linear-gradient(135deg, #6366f1, #2dd4bf);
}

.main-avatar {
  display: block;
  border: 3px solid #fff;
  background: #eef2ff;
}

.avatar-uploader {
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  overflow: hidden;
}

.avatar-uploader :deep(.el-upload) {
  width: 100%;
  height: 100%;
  border-radius: 50%;
}

.avatar-overlay {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  background: rgba(15, 28, 63, 0.55);
  color: #fff;
  font-size: 12px;
  opacity: 0;
  transition: opacity 0.25s;
  cursor: pointer;
}

.avatar-ring:hover .avatar-overlay {
  opacity: 1;
}

.avatar-name {
  margin: 16px 0 4px;
  font-size: 18px;
  font-weight: 600;
  color: #1e293b;
}

.avatar-role {
  margin: 0;
  font-size: 13px;
  color: #94a3b8;
}

.info-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.info-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  font-size: 14px;
  color: #475569;
}

.info-list li .el-icon {
  color: #6366f1;
  font-size: 16px;
}

.upload-hint {
  margin: 16px 0 0;
  font-size: 12px;
  color: #94a3b8;
  text-align: center;
  line-height: 1.5;
}

.profile-content {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.content-card {
  border-radius: 12px;
  border: none;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.content-card :deep(.el-card__body) {
  padding: 8px 24px 24px;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.profile-tabs {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.profile-tabs :deep(.el-tabs__content) {
  flex: 1;
}

.profile-tabs :deep(.el-tab-pane) {
  height: 100%;
}

.profile-tabs :deep(.el-tabs__header) {
  margin-bottom: 8px;
}

.profile-tabs :deep(.el-tabs__item) {
  font-size: 15px;
  font-weight: 500;
  padding: 0 24px;
  height: 48px;
}

.profile-tabs :deep(.el-tabs__active-bar) {
  background: linear-gradient(90deg, #6366f1, #8b5cf6);
  height: 3px;
  border-radius: 2px;
}

.tab-panel {
  padding-top: 8px;
}

.section-head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #f1f5f9;
}

.section-icon {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 10px;
  font-size: 22px;
  color: #6366f1;
  background: #eef2ff;
  flex-shrink: 0;
}

.section-icon.security {
  color: #f59e0b;
  background: #fffbeb;
}

.section-head h3 {
  margin: 0 0 4px;
  font-size: 16px;
  font-weight: 600;
  color: #1e293b;
}

.section-head p {
  margin: 0;
  font-size: 13px;
  color: #94a3b8;
}

.profile-form {
  width: 100%;
  max-width: 640px;
}

.profile-form :deep(.el-input__wrapper) {
  border-radius: 8px;
}

.gender-group :deep(.el-radio-button__inner) {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 8px 20px;
  border-radius: 8px !important;
}

.pwd-alert {
  margin-bottom: 20px;
  border-radius: 8px;
}

@media (max-width: 900px) {
  .profile-body {
    flex-direction: column;
  }

  .profile-sidebar {
    width: 100%;
  }

  .banner-content {
    flex-wrap: wrap;
  }

  .banner-time {
    width: 100%;
    text-align: left;
  }
}
</style>

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐