| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555 |
- <template>
- <div class="foods admin-page">
- <el-card shadow="never">
- <div slot="header" class="admin-page-header">
- <span class="admin-page-title">
- <i class="el-icon-apple" style="color: #67c23a; margin-right: 8px;"></i>
- 食材管理
- </span>
- <div class="header-actions">
- <el-input
- v-model="keyword"
- placeholder="搜索食材名称..."
- prefix-icon="el-icon-search"
- clearable
- size="mini"
- style="width: 200px; margin-right: 8px;"
- @keyup.enter.native="handleSearch"
- @clear="handleSearch"
- />
- <el-select
- v-model="categoryFilter"
- placeholder="分类筛选"
- size="mini"
- clearable
- style="width: 140px; margin-right: 8px;"
- @change="handleSearch"
- >
- <el-option label="全部分类" value="" />
- <el-option v-for="opt in categoryOptions" :key="opt" :label="opt" :value="opt" />
- </el-select>
- <el-tag
- v-if="sortState.length > 0"
- closable
- size="mini"
- class="sort-tag"
- @close="clearSort"
- @click="clearSort"
- >
- {{ sortLabel }}
- </el-tag>
- <el-button type="primary" size="mini" icon="el-icon-plus" @click="openCreate">新建食材</el-button>
- </div>
- </div>
- <el-table
- :data="list"
- v-loading="loading"
- border
- stripe
- size="mini"
- style="width: 100%"
- :max-height="tableHeight"
- >
- <el-table-column type="index" label="#" width="50" align="center" />
- <el-table-column prop="name" label="名称" min-width="120" fixed :class-name="sortClass('name')">
- <template slot-scope="{ row }">
- <span style="font-weight: 500;">{{ row.name }}</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('name',$event)" @dblclick.stop="onSortToggle('name')">名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="category" label="分类" width="120" align="center" :class-name="sortClass('category')">
- <template slot-scope="{ row }">
- <el-tag :type="categoryTagType(row.category)" size="mini" effect="plain">
- {{ row.category }}
- </el-tag>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('category',$event)" @dblclick.stop="onSortToggle('category')">分类<span v-if="sortIndex('category') !== null" class="sort-index" :class="sortClass('category') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('category') }}</span>{{ sortIcon('category') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="热量" width="100" align="right" :class-name="sortClass('calories')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.calories != null ? row.calories : '-' }}</span>
- <span class="num-unit">kcal</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('calories',$event)" @dblclick.stop="onSortToggle('calories')">热量<span v-if="sortIndex('calories') !== null" class="sort-index" :class="sortClass('calories') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('calories') }}</span>{{ sortIcon('calories') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="蛋白质" width="90" align="right" :class-name="sortClass('protein')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.protein != null ? row.protein : '-' }}</span>
- <span class="num-unit">g</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('protein',$event)" @dblclick.stop="onSortToggle('protein')">蛋白<span v-if="sortIndex('protein') !== null" class="sort-index" :class="sortClass('protein') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('protein') }}</span>{{ sortIcon('protein') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="脂肪" width="80" align="right" :class-name="sortClass('fat')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.fat != null ? row.fat : '-' }}</span>
- <span class="num-unit">g</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('fat',$event)" @dblclick.stop="onSortToggle('fat')">脂肪<span v-if="sortIndex('fat') !== null" class="sort-index" :class="sortClass('fat') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('fat') }}</span>{{ sortIcon('fat') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="碳水" width="80" align="right" :class-name="sortClass('carbs')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.carbs != null ? row.carbs : '-' }}</span>
- <span class="num-unit">g</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('carbs',$event)" @dblclick.stop="onSortToggle('carbs')">碳水<span v-if="sortIndex('carbs') !== null" class="sort-index" :class="sortClass('carbs') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('carbs') }}</span>{{ sortIcon('carbs') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="膳食纤维" width="90" align="right" :class-name="sortClass('fiber')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.fiber != null ? row.fiber : '-' }}</span>
- <span class="num-unit">g</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('fiber',$event)" @dblclick.stop="onSortToggle('fiber')">纤维<span v-if="sortIndex('fiber') !== null" class="sort-index" :class="sortClass('fiber') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('fiber') }}</span>{{ sortIcon('fiber') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="能量(kJ)" width="90" align="right" :class-name="sortClass('energyKj')">
- <template slot-scope="{ row }">
- <span class="num-val">{{ row.energyKj != null ? row.energyKj : '-' }}</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('energyKj',$event)" @dblclick.stop="onSortToggle('energyKj')">能量<span v-if="sortIndex('energyKj') !== null" class="sort-index" :class="sortClass('energyKj') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('energyKj') }}</span>{{ sortIcon('energyKj') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="胆固醇" width="90" align="right" :class-name="sortClass('cholesterol')">
- <template slot-scope="{ row }">
- <span v-if="row.cholesterol != null" class="num-val">{{ row.cholesterol }}<span class="num-unit">mg</span></span>
- <span v-else>-</span>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('cholesterol',$event)" @dblclick.stop="onSortToggle('cholesterol')">胆固醇<span v-if="sortIndex('cholesterol') !== null" class="sort-index" :class="sortClass('cholesterol') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('cholesterol') }}</span>{{ sortIcon('cholesterol') }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="status" label="状态" width="80" align="center" :class-name="sortClass('status')">
- <template slot-scope="{ row }">
- <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="mini" effect="dark">
- {{ row.status === 'active' ? '启用' : '停用' }}
- </el-tag>
- </template>
- <template slot="header">
- <span class="sort-header" @click.stop="onSortClick('status',$event)" @dblclick.stop="onSortToggle('status')">状态<span v-if="sortIndex('status') !== null" class="sort-index" :class="sortClass('status') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('status') }}</span>{{ sortIcon('status') }}</span>
- </template>
- </el-table-column>
- <el-table-column label="操作" width="180" fixed="right" align="center">
- <template slot-scope="{ row }">
- <el-button size="mini" type="primary" plain @click="editItem(row)">编辑</el-button>
- <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
- <el-button size="mini" plain>
- 更多<i class="el-icon-arrow-down el-icon--right"></i>
- </el-button>
- <el-dropdown-menu slot="dropdown">
- <el-dropdown-item command="detail" icon="el-icon-view">查看详情</el-dropdown-item>
- <el-dropdown-item command="delete" icon="el-icon-delete" divided style="color: #f56c6c;">删除</el-dropdown-item>
- </el-dropdown-menu>
- </el-dropdown>
- </template>
- </el-table-column>
- </el-table>
- <div class="pagination-wrap">
- <el-pagination
- @size-change="handleSizeChange"
- @current-change="handleCurrentChange"
- :current-page="page"
- :page-sizes="[10, 20, 50, 100]"
- :page-size="size"
- layout="total, sizes, prev, pager, next, jumper"
- :total="total"
- />
- </div>
- </el-card>
- <!-- 编辑/新建对话框 -->
- <el-dialog
- :title="editId ? '编辑食材' : '新建食材'"
- :visible.sync="dialogVisible"
- width="680px"
- :close-on-click-modal="false"
- >
- <el-form label-width="130px" size="small">
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="名称">
- <el-input v-model="form.name" placeholder="食材名称" />
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="分类">
- <el-select v-model="form.category" style="width:100%" placeholder="选择分类">
- <el-option v-for="opt in categoryOptions" :key="opt" :label="opt" :value="opt" />
- </el-select>
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="热量(kcal/100g)">
- <el-input-number v-model="form.calories" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="能量(kJ/100g)">
- <el-input-number v-model="form.energyKj" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="20">
- <el-col :span="8">
- <el-form-item label="蛋白质(g)">
- <el-input-number v-model="form.protein" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="碳水(g)">
- <el-input-number v-model="form.carbs" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="脂肪(g)">
- <el-input-number v-model="form.fat" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="20">
- <el-col :span="8">
- <el-form-item label="膳食纤维(g)">
- <el-input-number v-model="form.fiber" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="淀粉(g)">
- <el-input-number v-model="form.starch" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="胆固醇(mg)">
- <el-input-number v-model="form.cholesterol" :min="0" :precision="1" style="width:100%" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-form-item label="单位">
- <el-input v-model="form.unit" placeholder="如: 克、个、份" style="width: 200px;" />
- </el-form-item>
- <el-form-item label="应季月份">
- <el-checkbox-group v-model="form.months">
- <el-checkbox v-for="m in 12" :key="m" :label="m" border size="mini">{{ m }}月</el-checkbox>
- </el-checkbox-group>
- </el-form-item>
- <el-form-item label="状态">
- <el-radio-group v-model="form.status">
- <el-radio label="active">启用</el-radio>
- <el-radio label="inactive">停用</el-radio>
- </el-radio-group>
- </el-form-item>
- </el-form>
- <div slot="footer">
- <el-button @click="dialogVisible = false">取消</el-button>
- <el-button type="primary" :loading="submitting" @click="saveItem">保存</el-button>
- </div>
- </el-dialog>
- <!-- 详情对话框 -->
- <el-dialog title="食材详情" :visible.sync="detailVisible" width="500px">
- <el-descriptions :column="2" border size="small" v-if="detailData">
- <el-descriptions-item label="名称" :span="2">{{ detailData.name }}</el-descriptions-item>
- <el-descriptions-item label="分类">{{ detailData.category }}</el-descriptions-item>
- <el-descriptions-item label="状态">
- <el-tag :type="detailData.status === 'active' ? 'success' : 'info'" size="mini">{{ detailData.status === 'active' ? '启用' : '停用' }}</el-tag>
- </el-descriptions-item>
- <el-descriptions-item label="热量(kcal)">{{ detailData.calories || '-' }}</el-descriptions-item>
- <el-descriptions-item label="能量(kJ)">{{ detailData.energyKj || '-' }}</el-descriptions-item>
- <el-descriptions-item label="蛋白质(g)">{{ detailData.protein || '-' }}</el-descriptions-item>
- <el-descriptions-item label="脂肪(g)">{{ detailData.fat || '-' }}</el-descriptions-item>
- <el-descriptions-item label="碳水(g)">{{ detailData.carbs || '-' }}</el-descriptions-item>
- <el-descriptions-item label="膳食纤维(g)">{{ detailData.fiber || '-' }}</el-descriptions-item>
- <el-descriptions-item label="淀粉(g)">{{ detailData.starch || '-' }}</el-descriptions-item>
- <el-descriptions-item label="胆固醇(mg)">{{ detailData.cholesterol || '-' }}</el-descriptions-item>
- <el-descriptions-item label="单位">{{ detailData.unit || '-' }}</el-descriptions-item>
- <el-descriptions-item label="排序">{{ detailData.sortOrder != null ? detailData.sortOrder : '-' }}</el-descriptions-item>
- <el-descriptions-item label="应季月份" :span="2">
- <span v-if="detailData.months && detailData.months.length">{{ detailData.months.map(function(m) { return m + '月' }).join('、') }}</span>
- <span v-else style="color:#999">未设置</span>
- </el-descriptions-item>
- </el-descriptions>
- </el-dialog>
- </div>
- </template>
- <script>
- import { getFoodList, createFood, updateFood, deleteFood, getFoodDetail, saveFoodMonths } from '@/api/admin'
- export default {
- name: 'Foods',
- data() {
- return {
- submitting: false,
- loading: false,
- list: [],
- total: 0,
- page: 1,
- size: 20,
- categoryFilter: '',
- keyword: '',
- editId: null,
- dialogVisible: false,
- detailVisible: false,
- detailData: null,
- categoryOptions: [
- '蔬菜', '水果', '水产品', '肉类', '干果',
- '主食', '豆类及豆制品', '蛋类', '乳制品', '快餐', '汤'
- ],
- sortState: [],
- sortColLabels: {
- name: '\u540d\u79f0', category: '\u5206\u7c7b', calories: '\u70ed\u91cf', protein: '\u86cb\u767d',
- carbs: '\u78b3\u6c34', fat: '\u8102\u80aa', fiber: '\u7ea4\u7ef4', energyKj: '\u80fd\u91cf',
- starch: '\u6dc0\u7c89', cholesterol: '\u80c6\u56fa\u9187', status: '\u72b6\u6001'
- },
- form: {
- name: '',
- category: '',
- calories: null,
- protein: null,
- carbs: null,
- fat: null,
- fiber: null,
- energyKj: null,
- starch: null,
- cholesterol: null,
- unit: '克',
- status: 'active',
- months: []
- }
- }
- },
- computed: {
- tableHeight() {
- return window.innerHeight - 340
- },
- sortLabel() {
- if (!this.sortState || this.sortState.length === 0) return ''
- return this.sortState.map(s => {
- const col = this.sortColLabels[s.column] || s.column
- return s.order === 'asc' ? col + '↑' : col + '↓'
- }).join(' > ')
- }
- },
- created() {
- this.loadList()
- },
- methods: {
- async loadList() {
- this.loading = true
- try {
- const sortParams = { page: this.page, size: this.size }
- if (this.categoryFilter) sortParams.category = this.categoryFilter
- if (this.keyword) sortParams.keyword = this.keyword
- if (this.sortState.length > 0) sortParams.sort = this.sortState
- const res = await getFoodList(sortParams)
- this.list = res.data && res.data.records || []
- this.total = res.data && res.data.total || 0
- } catch (e) {
- this.$message && this.$message.error && this.$message.error('加载失败')
- } finally {
- this.loading = false
- }
- },
- handleSearch() {
- this.sortState = []
- this.loadList()
- },
- onSortClick(col, event) {
- const idx = this.sortState.findIndex(s => s.column === col)
- if (idx >= 0) {
- if (this.sortState[idx].order === 'asc') {
- this.sortState[idx].order = 'desc'
- } else {
- this.sortState.splice(idx, 1)
- }
- } else {
- this.sortState.push({ column: col, order: 'asc' })
- }
- this.loadList()
- },
- handleSizeChange(val) {
- this.size = val
- this.page = 1
- this.loadList()
- },
- handleCurrentChange(val) {
- this.page = val
- this.loadList()
- },
- openCreate() {
- this.editId = null
- this.form = { name: '', category: '', calories: null, protein: null, carbs: null, fat: null, fiber: null, energyKj: null, starch: null, cholesterol: null, unit: '克', status: 'active', months: [] }
- this.dialogVisible = true
- },
- async editItem(row) {
- this.editId = row.id
- this.form = {
- name: row.name || '',
- category: row.category || '',
- calories: row.calories,
- protein: row.protein,
- carbs: row.carbs,
- fat: row.fat,
- fiber: row.fiber,
- energyKj: row.energyKj,
- starch: row.starch,
- cholesterol: row.cholesterol,
- unit: row.unit || '克',
- status: row.status || 'active',
- months: []
- }
- // Load months for editing
- try {
- const res = await getFoodDetail(row.id)
- if (res.data && res.data.data && res.data.data.months) {
- this.form.months = res.data.data.months
- }
- } catch (e) {
- // months stay empty
- }
- this.dialogVisible = true
- },
- async saveItem() {
- this.submitting = true
- try {
- const data = { ...this.form }
- const months = data.months
- delete data.months
- let foodId = this.editId
- if (foodId) {
- data.id = foodId
- await updateFood(data)
- this.$message.success('更新成功')
- } else {
- const res = await createFood(data)
- foodId = res.data && res.data.data && res.data.data.id
- this.$message.success('创建成功')
- }
- // Save seasonal months
- if (foodId && months && months.length > 0) {
- try {
- await saveFoodMonths(foodId, months)
- } catch (e) {
- this.$message.warning('应季月份保存失败')
- }
- }
- this.dialogVisible = false
- this.loadList()
- } catch (e) {
- this.$message.error('保存失败')
- } finally {
- this.submitting = false
- }
- },
- deleteItem(row) {
- this.$confirm('确定删除 "' + row.name + '" 吗?', '提示', { type: 'warning' }).then(async () => {
- try {
- await deleteFood(row.id)
- this.$message.success('删除成功')
- this.loadList()
- } catch (e) {
- this.$message.error('删除失败')
- }
- }).catch(() => {})
- },
- showDetail(row) {
- this.detailData = row
- this.detailVisible = true
- },
- handleActionCmd(row, cmd) {
- switch (cmd) {
- case 'delete':
- this.deleteItem(row)
- break
- case 'detail':
- this.showDetail(row)
- break
- }
- },
- categoryTagType(category) {
- const map = {
- '蔬菜': 'success',
- '水果': 'warning',
- '水产品': 'primary',
- '肉类': 'danger',
- '干果': 'warning',
- '主食': '',
- '豆类及豆制品': 'success',
- '蛋类': 'danger',
- '乳制品': 'primary',
- '快餐': 'info',
- '汤': ''
- }
- return map[category] || ''
- }
- }
- }
- </script>
- <style scoped>
- .header-actions {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 4px;
- }
- .pagination-wrap {
- display: flex;
- justify-content: flex-end;
- padding: 16px 0 0;
- }
- .num-val {
- font-weight: 500;
- font-family: 'Helvetica Neue', Arial, sans-serif;
- }
- .num-unit {
- font-size: 11px;
- color: #999;
- margin-left: 2px;
- }
- .el-checkbox.is-bordered {
- margin-right: 6px;
- margin-bottom: 4px;
- }
- .el-descriptions {
- margin-top: 8px;
- }
- .sort-header {
- cursor: pointer;
- user-select: none;
- font-weight: 500;
- font-size: 12px;
- }
- .sort-header:hover {
- color: #409eff;
- }
- .sort-asc ::v-deep .el-table__header-wrapper th .cell,
- .sort-desc ::v-deep .el-table__header-wrapper th .cell {
- color: #409eff;
- font-weight: 600;
- }
- .sort-tag {
- margin-left: 8px;
- cursor: pointer;
- }
- </style>
|