NutritionProducts.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. <template>
  2. <div class="nutrition-products admin-page">
  3. <el-card shadow="never">
  4. <div slot="header" class="admin-page-header">
  5. <span class="admin-page-title">
  6. <i class="el-icon-first-aid-kit" style="color: #67c23a; margin-right: 8px;"></i>
  7. 营养产品管理
  8. </span>
  9. <div class="header-actions">
  10. <el-input
  11. v-model="keyword"
  12. placeholder="搜索产品名称..."
  13. prefix-icon="el-icon-search"
  14. clearable
  15. size="mini"
  16. style="width: 200px; margin-right: 8px;"
  17. @keyup.enter.native="handleSearch"
  18. @clear="handleSearch"
  19. />
  20. <el-select
  21. v-model="categoryFilter"
  22. placeholder="分类筛选"
  23. size="mini"
  24. clearable
  25. style="width: 140px; margin-right: 8px;"
  26. @change="handleSearch"
  27. >
  28. <el-option label="全部分类" value="" />
  29. <el-option v-for="opt in categoryOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
  30. </el-select>
  31. <el-select
  32. v-model="brandFilter"
  33. placeholder="品牌筛选"
  34. size="mini"
  35. clearable
  36. style="width: 140px; margin-right: 8px;"
  37. @change="handleSearch"
  38. >
  39. <el-option label="全部品牌" value="" />
  40. <el-option v-for="opt in brandOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
  41. </el-select>
  42. </div>
  43. </div>
  44. <div class="adm-table-wrap">
  45. <el-table
  46. :data="list"
  47. v-loading="loading"
  48. border
  49. stripe
  50. size="mini"
  51. style="width: 100%"
  52. height="100%"
  53. >
  54. <el-table-column type="index" label="#" width="50" align="center" />
  55. <el-table-column label="图片" width="100" align="center">
  56. <template slot-scope="{ row }">
  57. <el-image
  58. v-if="productImage(row)"
  59. :src="productImage(row)"
  60. :preview-src-list="productImageList(row)"
  61. fit="cover"
  62. style="width: 60px; height: 60px; border-radius: 4px;"
  63. lazy
  64. />
  65. <span v-else class="no-data">-</span>
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="产品名称" min-width="200">
  69. <template slot-scope="{ row }">
  70. <span style="font-weight: 500;">{{ row.name }}</span>
  71. </template>
  72. <template slot="header">
  73. <span class="sort-header" @click.stop="onSortClick('name', $event)">产品名称{{ sortIcon('name') }}</span>
  74. </template>
  75. </el-table-column>
  76. <el-table-column label="品牌" width="110" align="center">
  77. <template slot-scope="{ row }">
  78. <span>{{ brandName(row.brandId) }}</span>
  79. </template>
  80. <template slot="header">
  81. <span class="sort-header" @click.stop="onSortClick('brandId', $event)">品牌<span v-if="sortIndex('brandId') !== null" class="sort-index" :class="sortClass('brandId') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('brandId') }}</span>{{ sortIcon('brandId') }}</span>
  82. </template>
  83. </el-table-column>
  84. <el-table-column label="分类" width="95" align="center">
  85. <template slot-scope="{ row }">
  86. <el-tag size="mini" effect="plain">{{ categoryName(row.categoryId) }}</el-tag>
  87. </template>
  88. <template slot="header">
  89. <span class="sort-header" @click.stop="onSortClick('categoryId', $event)">分类<span v-if="sortIndex('categoryId') !== null" class="sort-index" :class="sortClass('categoryId') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('categoryId') }}</span>{{ sortIcon('categoryId') }}</span>
  90. </template>
  91. </el-table-column>
  92. <el-table-column label="价格" width="120" align="right">
  93. <template slot-scope="{ row }">
  94. <span class="price-val">¥{{ row.price != null ? Number(row.price).toFixed(2) : '-' }}</span>
  95. <span v-if="row.originalPrice" class="price-orig">¥{{ row.originalPrice }}</span>
  96. </template>
  97. <template slot="header">
  98. <span class="sort-header" @click.stop="onSortClick('price', $event)">价格<span v-if="sortIndex('price') !== null" class="sort-index" :class="sortClass('price') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('price') }}</span>{{ sortIcon('price') }}</span>
  99. </template>
  100. </el-table-column>
  101. <el-table-column label="已售" width="70" align="center">
  102. <template slot-scope="{ row }">{{ row.soldNum != null ? row.soldNum : '-' }}</template>
  103. <template slot="header">
  104. <span class="sort-header" @click.stop="onSortClick('soldNum', $event)">已售{{ sortIcon('soldNum') }}</span>
  105. </template>
  106. </el-table-column>
  107. <el-table-column label="来源" width="90" align="center">
  108. <template slot-scope="{ row }">
  109. <span v-if="row.sourcePlatform">{{ row.sourcePlatform }}</span>
  110. <span v-else class="no-data">-</span>
  111. </template>
  112. </el-table-column>
  113. <el-table-column label="规格" width="100" align="center">
  114. <template slot-scope="{ row }">{{ row.spec || '-' }}</template>
  115. </el-table-column>
  116. <el-table-column label="成份/配料" min-width="200" show-overflow-tooltip>
  117. <template slot-scope="{ row }">
  118. <span v-if="row.ingredients">{{ row.ingredients }}</span>
  119. <span v-else class="no-data">-</span>
  120. </template>
  121. </el-table-column>
  122. <el-table-column label="关联商品" width="90" align="center">
  123. <template slot-scope="{ row }">
  124. <el-tag v-if="row.localProductId" type="success" size="mini">已关联</el-tag>
  125. <span v-else class="no-data">-</span>
  126. </template>
  127. </el-table-column>
  128. <el-table-column label="操作" width="110" align="center" fixed="right">
  129. <template slot-scope="{ row }">
  130. <el-button type="text" size="mini" @click="showDetail(row.id)">详情</el-button>
  131. <el-link v-if="row.sourceUrl" :href="row.sourceUrl" target="_blank" type="primary" :underline="false" style="margin-left: 6px;">原平台</el-link>
  132. </template>
  133. </el-table-column>
  134. </el-table>
  135. </div>
  136. <!-- 分页 -->
  137. <div class="pagination-wrapper">
  138. <el-pagination
  139. background
  140. layout="total, sizes, prev, pager, next, jumper"
  141. :total="total"
  142. :page-sizes="[10, 20, 50, 100]"
  143. :page-size="pageSize"
  144. :current-page.sync="page"
  145. @size-change="handleSizeChange"
  146. @current-change="handlePageChange"
  147. />
  148. </div>
  149. </el-card>
  150. <!-- 详情对话框 -->
  151. <el-dialog
  152. title="营养产品详情"
  153. :visible.sync="detailVisible"
  154. width="800px"
  155. :close-on-click-modal="false"
  156. >
  157. <div v-loading="detailLoading" v-if="detailData" style="max-height: 60vh; overflow-y: auto;">
  158. <el-row :gutter="12" style="margin-bottom: 12px;">
  159. <el-col :span="8">
  160. <el-image
  161. v-if="detailImages.length"
  162. :src="detailImages[0]"
  163. :preview-src-list="detailImages"
  164. fit="cover"
  165. style="width: 100%; height: 180px; border-radius: 6px;"
  166. />
  167. <div v-else class="no-data" style="height: 180px; display: flex; align-items: center; justify-content: center;">暂无图片</div>
  168. </el-col>
  169. <el-col :span="16">
  170. <el-descriptions :column="2" border size="mini">
  171. <el-descriptions-item label="产品名称" :span="2">{{ detailData.product.name }}</el-descriptions-item>
  172. <el-descriptions-item label="品牌">{{ brandName(detailData.product.brandId) }}</el-descriptions-item>
  173. <el-descriptions-item label="分类">{{ categoryName(detailData.product.categoryId) }}</el-descriptions-item>
  174. <el-descriptions-item label="价格">¥{{ detailData.product.price != null ? Number(detailData.product.price).toFixed(2) : '-' }}</el-descriptions-item>
  175. <el-descriptions-item label="原价">¥{{ detailData.product.originalPrice || '-' }}</el-descriptions-item>
  176. <el-descriptions-item label="已售">{{ detailData.product.soldNum != null ? detailData.product.soldNum : '-' }}</el-descriptions-item>
  177. <el-descriptions-item label="来源平台">{{ detailData.product.sourcePlatform || '-' }}</el-descriptions-item>
  178. <el-descriptions-item label="规格">{{ detailData.product.spec || '-' }}</el-descriptions-item>
  179. <el-descriptions-item label="净含量">{{ detailData.product.netContent || '-' }}</el-descriptions-item>
  180. <el-descriptions-item label="剂型">{{ detailData.product.form || '-' }}</el-descriptions-item>
  181. <el-descriptions-item label="保质期">{{ detailData.product.shelfLife || '-' }}</el-descriptions-item>
  182. <el-descriptions-item label="适用人群">{{ detailData.product.suitableFor || '-' }}</el-descriptions-item>
  183. <el-descriptions-item label="成份/配料表">{{ detailData.product.ingredients || '-' }}</el-descriptions-item>
  184. <el-descriptions-item label="原平台链接" :span="2">
  185. <el-link v-if="detailData.product.sourceUrl" :href="detailData.product.sourceUrl" target="_blank" type="primary" :underline="false">
  186. {{ detailData.product.sourceUrl }}
  187. </el-link>
  188. <span v-else class="no-data">-</span>
  189. </el-descriptions-item>
  190. <el-descriptions-item label="关联商品ID" :span="2">
  191. <el-tag v-if="detailData.product.localProductId" type="success">
  192. {{ detailData.product.localProductId }}
  193. </el-tag>
  194. <span v-else class="no-data">未关联</span>
  195. </el-descriptions-item>
  196. <el-descriptions-item label="产品描述" :span="2">
  197. {{ detailData.product.description || '-' }}
  198. </el-descriptions-item>
  199. </el-descriptions>
  200. </el-col>
  201. </el-row>
  202. <el-divider content-position="left">成份明细</el-divider>
  203. <el-table v-if="detailData.ingredients && detailData.ingredients.length" :data="detailData.ingredients" size="mini" border stripe>
  204. <el-table-column prop="name" label="成分名" min-width="150" />
  205. <el-table-column prop="ingredientType" label="类型" width="100" />
  206. <el-table-column prop="strain" label="菌株号" width="120" />
  207. <el-table-column label="每份含量" width="120">
  208. <template slot-scope="{ row }">{{ row.amountPerServing }} {{ row.unit }}</template>
  209. </el-table-column>
  210. <el-table-column prop="purpose" label="功效" min-width="150" />
  211. <div v-else class="no-data">暂无成份数据</div>
  212. <el-divider content-position="left">平台价格</el-divider>
  213. <el-table v-if="detailData.platformListings && detailData.platformListings.length" :data="detailData.platformListings" size="mini" border stripe>
  214. <el-table-column prop="platform" label="平台" width="100" />
  215. <el-table-column label="当前价格" width="120">
  216. <template slot-scope="{ row }">¥{{ row.currentPrice }}</template>
  217. </el-table-column>
  218. <el-table-column label="原价" width="120">
  219. <template slot-scope="{ row }">¥{{ row.originalPrice || '-' }}</template>
  220. </el-table-column>
  221. <el-table-column prop="priceUnit" label="价格单位" width="100" />
  222. <el-table-column prop="salesCount" label="销量" width="80" />
  223. <el-table-column prop="platformUrl" label="链接" min-width="200">
  224. <template slot-scope="{ row }">
  225. <el-link v-if="row.platformUrl" :href="row.platformUrl" target="_blank" type="primary" :underline="false">
  226. {{ row.platformUrl | truncate(40) }}
  227. </el-link>
  228. <span v-else class="no-data">-</span>
  229. </template>
  230. </el-table-column>
  231. <div v-else class="no-data">暂无平台价格数据</div>
  232. <el-divider content-position="left">本平台关联商品</el-divider>
  233. <div v-if="detailData.localProduct">
  234. <el-tag type="success">已关联商品 ID: {{ detailData.localProduct.id }}</el-tag>
  235. <p style="margin-top: 8px;">名称: {{ detailData.localProduct.name }}</p>
  236. <p>价格: ¥{{ (detailData.localProduct.price / 100).toFixed(2) }}</p>
  237. </div>
  238. <div v-else class="no-data">未关联本平台商品</div>
  239. </el-table>
  240. </el-table>
  241. </div>
  242. <div v-else-if="detailLoading" style="text-align: center; padding: 40px;">
  243. <i class="el-icon-loading" style="font-size: 24px;"></i>
  244. </div>
  245. <div v-else class="no-data" style="text-align: center; padding: 40px;">未找到产品信息</div>
  246. </el-dialog>
  247. </div>
  248. </template>
  249. <script>
  250. import { getCategoryList, getBrandList, searchProducts, getProductDetail } from '@/api/nutritionProduct'
  251. import SortMixin from '@/mixins/SortMixin'
  252. export default {
  253. name: 'NutritionProducts',
  254. mixins: [SortMixin],
  255. data() {
  256. return {
  257. keyword: '',
  258. categoryFilter: '',
  259. brandFilter: '',
  260. list: [],
  261. sortableColumns: { name: '产品名称', soldNum: '已售', price: '价格', brandId: '品牌', categoryId: '分类' },
  262. total: 0,
  263. page: 1,
  264. pageSize: 20,
  265. loading: false,
  266. categories: [],
  267. brands: [],
  268. detailVisible: false,
  269. detailLoading: false,
  270. detailData: null,
  271. tableHeight: window.innerHeight - 260
  272. }
  273. },
  274. computed: {
  275. categoryOptions() {
  276. return this.categories.map(c => ({ value: c.id, label: c.name }))
  277. },
  278. brandOptions() {
  279. return this.brands.map(b => ({ value: b.id, label: b.name }))
  280. },
  281. detailImages() {
  282. if (!this.detailData || !this.detailData.product) return []
  283. return this.parseImages(this.detailData.product)
  284. }
  285. },
  286. created() {
  287. this.loadCategories()
  288. this.loadBrands()
  289. this.loadList()
  290. },
  291. methods: {
  292. parseImages(row) {
  293. const images = []
  294. if (row.productImages) {
  295. try {
  296. const parsed = JSON.parse(row.productImages)
  297. if (Array.isArray(parsed)) {
  298. return parsed.filter(u => u)
  299. }
  300. if (typeof parsed === 'string') {
  301. return parsed.split(',').filter(u => u)
  302. }
  303. } catch (e) {
  304. // ignore
  305. }
  306. }
  307. if (row.imageUrl) {
  308. images.push(row.imageUrl)
  309. }
  310. return images
  311. },
  312. productImage(row) {
  313. const imgs = this.parseImages(row)
  314. return imgs.length ? imgs[0] : ''
  315. },
  316. productImageList(row) {
  317. return this.parseImages(row)
  318. },
  319. async loadCategories() {
  320. try {
  321. const res = await getCategoryList()
  322. this.categories = res.data || []
  323. } catch (e) {
  324. // ignore
  325. }
  326. },
  327. async loadBrands() {
  328. try {
  329. const res = await getBrandList()
  330. this.brands = res.data || []
  331. } catch (e) {
  332. // ignore
  333. }
  334. },
  335. async loadList() {
  336. this.loading = true
  337. try {
  338. const params = {
  339. page: this.page,
  340. pageSize: this.pageSize
  341. }
  342. if (this.keyword) params.name = this.keyword
  343. if (this.categoryFilter) params.categoryId = this.categoryFilter
  344. if (this.brandFilter) params.brandId = this.brandFilter
  345. if (this.sortState.length > 0) params.sort = this.sortState
  346. const res = await searchProducts(params)
  347. const data = res.data || {}
  348. this.list = data.records || []
  349. this.total = data.total || 0
  350. } catch (e) {
  351. this.list = []
  352. this.total = 0
  353. } finally {
  354. this.loading = false
  355. }
  356. },
  357. handleSearch() {
  358. this.page = 1
  359. this.loadList()
  360. },
  361. handleSizeChange(size) {
  362. this.pageSize = size
  363. this.page = 1
  364. this.loadList()
  365. },
  366. handlePageChange(page) {
  367. this.page = page
  368. this.loadList()
  369. },
  370. categoryName(id) {
  371. if (!id) return '-'
  372. const found = this.categories.find(c => c.id === id)
  373. return found ? found.name : '-'
  374. },
  375. brandName(id) {
  376. if (!id) return '-'
  377. const found = this.brands.find(b => b.id === id)
  378. return found ? found.name : '-'
  379. },
  380. async showDetail(productId) {
  381. this.detailVisible = true
  382. this.detailLoading = true
  383. this.detailData = null
  384. try {
  385. const res = await getProductDetail(productId)
  386. this.detailData = res.data
  387. } catch (e) {
  388. this.$message.error('获取详情失败')
  389. } finally {
  390. this.detailLoading = false
  391. }
  392. }
  393. }
  394. }
  395. </script>
  396. <style scoped>
  397. .nutrition-products {
  398. padding: 16px;
  399. }
  400. .admin-page-header {
  401. display: flex;
  402. justify-content: space-between;
  403. align-items: center;
  404. }
  405. .admin-page-title {
  406. font-size: 16px;
  407. font-weight: 600;
  408. }
  409. .header-actions {
  410. display: flex;
  411. align-items: center;
  412. }
  413. .pagination-wrapper {
  414. display: flex;
  415. justify-content: flex-end;
  416. margin-top: 14px;
  417. }
  418. .price-val {
  419. font-weight: 600;
  420. color: #e74c3c;
  421. }
  422. .price-orig {
  423. margin-left: 6px;
  424. color: #999;
  425. text-decoration: line-through;
  426. font-size: 12px;
  427. }
  428. .no-data {
  429. color: #999;
  430. }
  431. .sort-header {
  432. cursor: pointer;
  433. user-select: none;
  434. font-weight: 500;
  435. font-size: 12px;
  436. }
  437. .sort-header:hover {
  438. color: #409eff;
  439. }
  440. </style>