877 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<div class="content-wrapper">
<!-- 操作区域 -->
<div class="operation-header">
<h2>我的酒单</h2>
<div class="operation-buttons">
<el-button type="primary" :icon="Plus" @click="showAddBeerDialog">
新酒上枪
</el-button>
<el-button type="success" :icon="Download" @click="showExportMenuDialog">
整版酒单
</el-button>
<el-button type="info" :icon="Refresh" @click="refreshData">
刷新
</el-button>
</div>
</div>
<!-- 在售酒款列表 -->
<div class="tap-list" v-loading="loading">
<div v-if="tapList.length === 0" class="empty-state">
<el-empty description="暂无在售酒款" />
</div>
<div v-else class="tap-items">
<div v-for="item in tapList" :key="item.id" class="tap-item">
<!-- 酒头编号 -->
<div class="tap-number">
<span class="tap-badge">{{ item.tapNo }}</span>
</div>
<!-- 酒款信息 -->
<div class="beer-info">
<div class="beer-image">
<img
:src="item.beerCover || '/images/default-beer.png'"
:alt="item.beerName"
/>
</div>
<div class="beer-details">
<h3 class="beer-name">{{ item.beerName }}</h3>
<p class="beer-brand">{{ item.brandName }}</p>
<p class="beer-style">
{{ item.beerStyles || item.customStyle }}
</p>
<p class="beer-abv">ABV {{ item.beerAbv }}%</p>
</div>
</div>
<!-- 规格价格 -->
<div class="specs-info">
<div
v-for="spec in item.specList"
:key="spec.id"
class="spec-item"
>
<span class="spec-name">{{ spec.specName }}</span>
<span class="spec-price">{{ spec.specPrice }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="actions">
<el-button size="small" type="primary" @click="editSpecs(item)" class="action-btn">
修改规格
</el-button>
<el-button size="small" type="success" @click="exportSingleBeer(item)" class="action-btn">
导出图片
</el-button>
<el-button size="small" type="warning" @click="offTap(item)" class="action-btn">
下架
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 新酒上枪对话框 -->
<el-dialog
v-model="addBeerDialog.visible"
title="新酒上枪"
width="1000px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<BeerSelectionWizard
ref="beerSelectionWizardRef"
@submit="handleAddBeer"
@cancel="handleAddBeerCancel"
/>
</el-dialog>
<!-- 导出酒单对话框 -->
<el-dialog v-model="exportMenuDialog.visible" title="整版导出" width="900px">
<MenuExportWizard
ref="menuExportWizardRef"
:tap-list="tapList"
@submit="handleExportMenu"
@cancel="handleExportMenuCancel"
/>
</el-dialog>
<!-- 编辑规格价格对话框 -->
<el-dialog v-model="editSpecDialog.visible" title="编辑规格价格" width="900px">
<EditSpecForm
:tap-beer="editSpecDialog.data"
@submit="handleUpdateSpecs"
@cancel="editSpecDialog.visible = false"
/>
</el-dialog>
<!-- 单酒款导出对话框 -->
<el-dialog v-model="singleExportDialog.visible" title="导出单酒款图片" width="900px">
<SingleBeerExportWizard
ref="singleExportWizardRef"
:beer-data="singleExportDialog.beerData"
@submit="handleSingleBeerExport"
@cancel="singleExportDialog.visible = false"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Download, Refresh } from '@element-plus/icons-vue'
import {
getTapList,
addBeerToTap,
offTapBeer,
updateTapBeerSpecs
} from '@/api/barmgr/tap'
import { formatTime } from '@/utils/date-util'
import { generateWineMenu, generateSingleBeerPoster } from '@/api/template'
import BeerSelectionWizard from './components/BeerSelectionWizard.vue'
import EditSpecForm from './components/EditSpecForm.vue'
import MenuExportWizard from './components/MenuExportWizard.vue'
import SingleBeerExportWizard from './components/SingleBeerExportWizard.vue'
// 响应式数据
const loading = ref(false)
const tapList = ref([])
const beerSelectionWizardRef = ref()
const menuExportWizardRef = ref()
const singleExportWizardRef = ref()
const addBeerDialog = reactive({
visible: false
})
const exportMenuDialog = reactive({
visible: false
})
const editSpecDialog = reactive({
visible: false,
data: null
})
const singleExportDialog = reactive({
visible: false,
beerData: null
})
// 获取在售酒款列表
const fetchTapList = async () => {
try {
loading.value = true
const response = await getTapList({ pageNum: 1, pageSize: 1000 })
// axios 返回的数据在 response.data 中
const responseData = response.data
if (responseData && responseData.code === 200) {
// TableDataInfo 结构:{code: 200, msg: "查询成功", rows: [...], total: 1}
tapList.value = responseData.rows || []
} else {
ElMessage.error('获取酒款列表失败: ' + (responseData?.msg || '未知错误'))
}
} catch (error) {
ElMessage.error('获取酒款列表失败: ' + error.message)
} finally {
loading.value = false
}
}
// 刷新数据
const refreshData = async () => {
await fetchTapList()
ElMessage.success('数据刷新成功')
}
// 显示新酒上枪对话框
const showAddBeerDialog = () => {
addBeerDialog.visible = true
}
// 处理新酒上枪取消
const handleAddBeerCancel = () => {
addBeerDialog.visible = false
// 重置表单(下次打开时状态会重置)
if (beerSelectionWizardRef.value) {
beerSelectionWizardRef.value.resetForm()
}
}
// 显示导出酒单对话框
const showExportMenuDialog = () => {
exportMenuDialog.visible = true
}
// 处理导出酒单取消
const handleExportMenuCancel = () => {
exportMenuDialog.visible = false
// 重置向导状态(下次打开时状态会重置)
if (menuExportWizardRef.value) {
menuExportWizardRef.value.resetWizard()
}
}
// 处理新酒上枪
const handleAddBeer = async (data) => {
try {
const response = await addBeerToTap(data)
// 检查不同的响应数据结构
if (response.data && response.data.code === 200) {
ElMessage.success('上枪成功')
addBeerDialog.visible = false
// 重置表单
if (beerSelectionWizardRef.value) {
beerSelectionWizardRef.value.resetForm()
}
await refreshData()
} else if (response.code === 200) {
ElMessage.success('上枪成功')
addBeerDialog.visible = false
// 重置表单
if (beerSelectionWizardRef.value) {
beerSelectionWizardRef.value.resetForm()
}
await refreshData()
} else {
const errorMsg = (response.data && response.data.msg) || response.msg || '上枪失败'
throw new Error(errorMsg)
}
} catch (error) {
ElMessage.error('上枪失败: ' + error.message)
}
}
// 处理导出酒单
const handleExportMenu = async (exportData) => {
try {
// 构建请求参数,按照整版酒单编辑器的数据结构要求
const params = {
uuid: exportData.template.uuid || exportData.template.posterId || String(exportData.template.id), // 优先使用uuid字段
format: exportData.format || 'PDF'
}
// 按照 beer{index}_{field} 格式传递酒款数据
exportData.generateParams.beerList.forEach((beer, index) => {
// 基础酒款信息
params[`beer${index}_name`] = beer.beerName || ''
params[`beer${index}_name_en`] = beer.beerNameEn || ''
// 风格优先级custom_style > beer_style
params[`beer${index}_style`] = beer.customStyle || beer.beerStyles || ''
params[`beer${index}_custom_style`] = beer.customStyle || ''
params[`beer${index}_beer_styles_en`] = beer.beerStylesEn || ''
params[`beer${index}_abv`] = beer.beerAbv ? `ABV: ${beer.beerAbv}%` : ''
params[`beer${index}_ibu`] = beer.beerIbus ? `IBU: ${beer.beerIbus}` : ''
params[`beer${index}_og`] = beer.beerOg ? `OG: ${beer.beerOg}` : ''
params[`beer${index}_intro`] = beer.beerDesc || ''
params[`beer${index}_juice_content`] = beer.juiceContent || ''
params[`beer${index}_score`] = beer.beerScore || ''
params[`beer${index}_img`] = beer.cover || beer.beerCover || ''
// 鱼眼标处理逗号分隔的图片URL取第一张
let fisheyeLogo = beer.fisheyeLogo || ''
if (fisheyeLogo) {
// 按逗号分隔,取第一张图片
const logoArray = fisheyeLogo.split(',').map(url => url.trim()).filter(url => url)
fisheyeLogo = logoArray.length > 0 ? logoArray[0] : fisheyeLogo
}
// 如果鱼眼标为空,使用酒款图片并标记为圆形处理
if (!fisheyeLogo && (beer.cover || beer.beerCover)) {
fisheyeLogo = beer.cover || beer.beerCover
// 添加圆形处理标识
params[`beer${index}_fisheye_logo_crop`] = 'circle'
}
params[`beer${index}_fisheye_logo`] = fisheyeLogo
// 厂牌信息
params[`beer${index}_brewery_name`] = beer.breweryName || beer.brandName || ''
params[`beer${index}_brewery_name_en`] = beer.breweryNameEn || ''
params[`beer${index}_brewery_logo`] = beer.brandLogo || beer.breweryLogo || ''
params[`beer${index}_country`] = beer.country || ''
params[`beer${index}_city`] = beer.city || ''
// 酒头信息
params[`beer${index}_tapno`] = beer.tapNo ? String(beer.tapNo) : ''
// 规格价格信息
params[`beer${index}_price`] = beer.specs && beer.specs[0] ? String(beer.specs[0].specPrice) : ''
params[`beer${index}_spec`] = beer.specs && beer.specs[0] ? beer.specs[0].specName : ''
// 如果有多个规格,添加第二个规格
if (beer.specs && beer.specs[1]) {
params[`beer${index}_spec1`] = beer.specs[1].specName
params[`beer${index}_price1`] = String(beer.specs[1].specPrice)
}
})
ElMessage({
message: '正在生成酒单,请稍候...',
type: 'info',
duration: 0
})
// 调用现有的整版酒单生成接口
const response = await generateWineMenu(params)
if (response.data && response.data.code === 200 && response.data.data && response.data.data.image) {
const result = response.data
// 现有接口返回base64图片需要转换为可下载的文件
const base64Image = result.data.image
// 将base64转换为blob
const byteCharacters = atob(base64Image)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
// 根据格式设置正确的MIME类型和文件扩展名
let mimeType = 'image/png'
let fileExtension = 'png'
if (result.data.format === 'base64_pdf') {
mimeType = 'application/pdf'
fileExtension = 'pdf'
}
const blob = new Blob([byteArray], { type: mimeType })
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `酒单_${new Date().getTime()}.${fileExtension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
ElMessage.closeAll() // 关闭loading消息
if (result.data.format === 'base64_pdf') {
ElMessage.success('PDF酒单导出成功')
} else {
ElMessage.success('PNG酒单导出成功')
}
exportMenuDialog.visible = false
// 重置向导状态
if (menuExportWizardRef.value) {
menuExportWizardRef.value.resetWizard()
}
} else {
// axios响应错误处理
const errorMsg = response.data?.msg || response.msg || '生成失败'
throw new Error(errorMsg)
}
} catch (error) {
ElMessage.closeAll() // 关闭loading消息
ElMessage.error('导出酒单失败: ' + error.message)
}
}
// 下架酒款
const offTap = async (row) => {
try {
await ElMessageBox.confirm(
`确定要下架酒头 ${row.tapNo} 上的"${row.beerName}"吗?`,
'下架确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await offTapBeer(row.tapId)
// axios 返回的数据在 response.data 中
const responseData = response.data || response
if (responseData && responseData.code === 200) {
ElMessage.success('下架成功')
await refreshData()
} else {
throw new Error(responseData?.msg || '下架失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('下架失败: ' + (error.message || error))
}
}
}
// 编辑规格价格
const editSpecs = (row) => {
// 安全地复制数据避免Vue内部属性
editSpecDialog.data = {
id: row.id,
tapBeerId: row.id, // 确保有tapBeerId
tapId: row.tapId,
tapNo: row.tapNo,
beerId: row.beerId,
beerName: row.beerName,
beerCover: row.beerCover,
brandName: row.brandName,
beerStyles: row.beerStyles,
customStyle: row.customStyle,
specList: row.specList || []
}
editSpecDialog.visible = true
}
// 导出单酒款图片
const exportSingleBeer = (row) => {
// 安全地复制数据避免Vue内部属性
singleExportDialog.beerData = {
id: row.id,
tapId: row.tapId,
tapNo: row.tapNo,
beerId: row.beerId,
beerName: row.beerName,
beerNameEn: row.beerNameEn,
beerCover: row.beerCover,
cover: row.cover,
brandName: row.brandName,
brandLogo: row.brandLogo,
beerStyles: row.beerStyles,
customStyle: row.customStyle,
beerStylesEn: row.beerStylesEn,
beerAbv: row.beerAbv,
beerIbus: row.beerIbus,
beerOg: row.beerOg,
beerDesc: row.beerDesc,
juiceContent: row.juiceContent,
fisheyeLogo: row.fisheyeLogo,
breweryName: row.breweryName,
breweryNameEn: row.breweryNameEn,
country: row.country,
city: row.city,
specList: row.specList || []
}
singleExportDialog.visible = true
}
// 处理单酒款导出
const handleSingleBeerExport = async (exportData) => {
try {
// //console.log('导出单酒款数据:', exportData)
// 构建请求参数,用于单酒款海报
const params = {
uuid: exportData.template.uuid || String(exportData.template.id),
format: exportData.format || 'PNG'
}
// 传递完整的酒款数据,确保兼容所有模板组件
const beerData = exportData.beerData
// 酒款信息组件
params[`beer_name`] = beerData.beerName || ''
params[`beer_name_en`] = beerData.beerNameEn || beerData.beerEnglishName || ''
params[`beer_image`] = beerData.beerCover || beerData.cover || ''
// 风格优先级custom_style > beer_style
params[`beer_style`] = beerData.customStyle || beerData.beerStyles || ''
params[`custom_style`] = beerData.customStyle || ''
params[`beer_styles_en`] = beerData.beerStylesEn || ''
params[`abv`] = beerData.beerAbv ? `ABV: ${beerData.beerAbv}%` : ''
params[`ibu`] = beerData.beerIbus ? `IBU: ${beerData.beerIbus}` : ''
params[`og`] = beerData.beerOg ? `OG: ${beerData.beerOg}` : ''
params[`intro`] = beerData.beerDesc || beerData.beerIntro || beerData.beerDescription || ''
params[`juice_content`] = beerData.juiceContent || ''
params[`score`] = beerData.beerScore || ''
// 鱼眼标处理逗号分隔的图片URL取第一张
let fisheyeLogo = beerData.fisheyeLogo || ''
if (fisheyeLogo) {
// 按逗号分隔,取第一张图片
const logoArray = fisheyeLogo.split(',').map(url => url.trim()).filter(url => url)
fisheyeLogo = logoArray.length > 0 ? logoArray[0] : fisheyeLogo
}
// 如果鱼眼标为空,使用酒款图片并标记为圆形处理
if (!fisheyeLogo && (beerData.beerCover || beerData.cover)) {
fisheyeLogo = beerData.beerCover || beerData.cover
// 添加圆形处理标识
params[`fisheye_logo_crop`] = 'circle'
}
params[`fisheye_logo`] = fisheyeLogo
// 厂牌信息组件
params[`brewery_name`] = beerData.breweryName || beerData.brandName || ''
params[`brewery_name_en`] = beerData.breweryNameEn || ''
params[`country`] = beerData.country || ''
params[`city`] = beerData.city || ''
params[`brewery_logo`] = beerData.breweryLogo || beerData.brandLogo || ''
// 市售规格组件
params[`tapno`] = beerData.tapNo || ''
if (beerData.specList && beerData.specList.length > 0) {
params[`spec1_name`] = beerData.specList[0].specName || ''
params[`spec1_price`] = beerData.specList[0].specPrice ? `${beerData.specList[0].specPrice}` : ''
if (beerData.specList.length > 1) {
params[`spec2_name`] = beerData.specList[1].specName || ''
params[`spec2_price`] = beerData.specList[1].specPrice ? `${beerData.specList[1].specPrice}` : ''
}
}
// 其他常用组件(预留)
params[`qtcy_text`] = ''
params[`qtcy_image`] = ''
params[`qtcy_avatar`] = ''
params[`qtcy_qrcode`] = ''
//console.log('单酒款图片生成参数:', params)
// 调用生成图片接口
const response = await generateSingleBeerPoster(params)
if (response.data?.code === 200) {
const { url } = response.data.data
if (url) {
// 生成下载文件名
const fileExtension = params.format.toLowerCase()
const fileName = `${beerData.beerName || '酒款'}_${beerData.tapNo}号酒头.${fileExtension}`
// 下载文件
try {
const downloadResponse = await fetch(url)
const blob = await downloadResponse.blob()
// 触发下载
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
ElMessage.success('单酒款图片导出成功')
singleExportDialog.visible = false
// 重置弹窗状态
if (singleExportWizardRef.value) {
singleExportWizardRef.value.resetState()
}
} catch (downloadError) {
// 如果下载失败,直接打开链接
window.open(url, '_blank')
ElMessage.success('图片已在新窗口打开,请右键保存')
singleExportDialog.visible = false
// 重置弹窗状态
if (singleExportWizardRef.value) {
singleExportWizardRef.value.resetState()
}
}
} else {
throw new Error('生成的图片URL为空')
}
} else {
throw new Error(response.data?.msg || response.msg || '生成图片失败')
}
} catch (error) {
console.error('导出单酒款图片失败:', error)
ElMessage.error('导出失败: ' + error.message)
}
}
// 处理更新规格价格
const handleUpdateSpecs = async (data) => {
try {
//console.log('Update specs data:', data)
const response = await updateTapBeerSpecs(data)
//console.log('Update specs response:', response)
// axios 返回的数据在 response.data 中
const responseData = response.data || response
if (responseData && responseData.code === 200) {
ElMessage.success('价格更新成功')
editSpecDialog.visible = false
await refreshData()
} else {
throw new Error(responseData?.msg || '价格更新失败')
}
} catch (error) {
console.error('Update specs error:', error)
ElMessage.error('价格更新失败: ' + error.message)
}
}
// 组件挂载时获取数据
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.app-container {
min-height: 100vh;
height: auto; /* 允许高度自动扩展 */
background: #f5f7fa;
padding: 20px 0 80px 0; /* 增加底部padding为版权信息留出空间 */
position: relative; /* 确保背景能够包含所有内容 */
}
.content-wrapper {
max-width: 1200px;
margin: 0 auto 20px auto; /* 增加底部margin与背景分离 */
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative; /* 确保相对定位 */
z-index: 1; /* 确保内容在背景之上 */
}
.operation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.operation-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.operation-buttons {
display: flex;
gap: 12px;
}
.tap-list {
padding: 0;
min-height: 200px; /* 确保列表区域有最小高度 */
position: relative;
}
.empty-state {
padding: 60px 32px;
text-align: center;
}
.tap-items {
padding: 0;
}
.tap-item {
display: flex;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.tap-item:hover {
background-color: #fafafa;
}
.tap-item:last-child {
border-bottom: none;
}
.tap-number {
margin-right: 24px;
}
.tap-badge {
display: inline-block;
width: 48px;
height: 48px;
line-height: 48px;
text-align: center;
background: #409eff;
color: white;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
}
.beer-info {
display: flex;
align-items: center;
flex: 1;
margin-right: 24px;
}
.beer-image {
margin-right: 16px;
}
.beer-image img {
width: 60px;
height: 90px;
border-radius: 8px;
object-fit: cover;
border: 2px solid #f0f0f0;
}
.beer-details {
flex: 1;
}
.beer-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.4;
}
.beer-brand {
margin: 0 0 2px 0;
font-size: 14px;
color: #606266;
line-height: 1.4;
}
.beer-style {
margin: 0 0 2px 0;
font-size: 12px;
color: #67c23a;
line-height: 1.4;
}
.beer-abv {
margin: 0;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.specs-info {
margin-right: 24px;
min-width: 120px;
}
.spec-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 14px;
width: 200px;
margin-right: 100px;
}
.spec-item:last-child {
margin-bottom: 0;
}
.spec-name {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.spec-price {
font-size: 16px;
font-weight: 600;
color: #e6a23c;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 80px;
}
.actions .el-button {
width: 100%;
height: 32px;
font-size: 12px;
}
.action-btn {
width: 100% !important;
height: 32px !important;
font-size: 12px !important;
margin: 0 !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.content-wrapper {
margin: 0 16px;
border-radius: 8px;
}
.operation-header {
padding: 16px 20px;
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.operation-buttons {
justify-content: center;
}
.tap-item {
padding: 16px 20px;
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.tap-number {
margin-right: 0;
text-align: center;
}
.beer-info {
margin-right: 0;
justify-content: center;
}
.specs-info {
margin-right: 0;
min-width: auto;
}
.actions {
flex-direction: row;
justify-content: center;
}
}
/* 确保页面整体布局正确 */
:deep(.ele-pro-body) {
min-height: auto !important;
height: auto !important;
}
/* 确保主内容区域能够正确扩展 */
:deep(.ele-pro-layout-body) {
min-height: auto !important;
display: flex;
flex-direction: column;
}
/* 确保路由视图容器能够正确扩展 */
:deep(.router-view-wrapper) {
flex: 1;
display: flex;
flex-direction: column;
}
</style>