List.vue 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. <template>
  2. <div class="page-card">
  3. <div class="toolbar">
  4. <span>模板由管理后台上传识版。本页列出官方模板和本厂模板,点套打即可按标准底图填入工单/产品数据。</span>
  5. </div>
  6. <div class="tpl-grid" v-loading="loading">
  7. <el-card class="tpl-card" shadow="hover" v-for="row in rows" :key="row.id">
  8. <div class="cover" :style="coverBox(row)" :class="{ clickable: !!coverOf(row) }" @click="openPreview(row)">
  9. <img v-if="coverOf(row)" class="cover-img" :src="coverOf(row)" :alt="row.name" />
  10. <div v-else class="cover-empty">暂无底图</div>
  11. <div v-if="coverOf(row)" class="cells">
  12. <div
  13. v-for="cell in cellsOf(row)"
  14. :key="cell.id"
  15. class="cell"
  16. :style="cellStyle(cell)"
  17. :title="cellLabel(cell)"
  18. />
  19. </div>
  20. <span v-if="coverOf(row)" class="zoom-tip">点击放大</span>
  21. </div>
  22. <h3 :title="row.name">{{ row.name }}</h3>
  23. <p class="meta">{{ sourceText(row) }} · {{ paperText(row) }} · {{ cellsOf(row).length }} 格</p>
  24. <p v-if="row.market && row.market.on_sale" class="listed">
  25. 商城在售 · 单次 {{ row.market.price_single }} / 买断 {{ row.market.price_buyout }}
  26. </p>
  27. <el-button v-if="hasPerms('print.do')" size="small" type="success" @click="openPrint({ templateId: row.id })">套打</el-button>
  28. <el-button
  29. v-if="store.isOwner && row.can_publish"
  30. size="small"
  31. type="warning"
  32. plain
  33. @click="openPublish(row)"
  34. >{{ row.market && row.market.on_sale ? '改商城价' : '上架商城' }}</el-button>
  35. <el-button v-if="hasPerms('template.delete') && row.can_delete !== false && Number(row.is_system) !== 1" size="small" type="danger" plain @click="onDel(row)">删除</el-button>
  36. </el-card>
  37. </div>
  38. <el-empty v-if="!loading && !rows.length" description="还没有可用模板。请联系管理员在后台上传标准图和色块图。" />
  39. <PublishMarketDialog v-model="publishVisible" :template="publishRow" @done="load" />
  40. <el-dialog
  41. v-model="previewVisible"
  42. :title="previewRow ? previewRow.name : '模板预览'"
  43. width="auto"
  44. top="4vh"
  45. append-to-body
  46. class="tpl-preview-dialog"
  47. destroy-on-close
  48. >
  49. <div v-if="previewRow" class="preview-stage">
  50. <div class="preview-cover" :style="previewBox(previewRow)">
  51. <img class="cover-img" :src="coverOf(previewRow)" :alt="previewRow.name" />
  52. <div class="cells">
  53. <div
  54. v-for="cell in cellsOf(previewRow)"
  55. :key="cell.id"
  56. class="cell"
  57. :style="cellStyle(cell)"
  58. :title="cellLabel(cell)"
  59. />
  60. </div>
  61. </div>
  62. </div>
  63. </el-dialog>
  64. </div>
  65. </template>
  66. <script setup>
  67. import { onMounted, ref } from 'vue'
  68. import { ElMessage, ElMessageBox } from 'element-plus'
  69. import { templateDel, templateList } from '@/api/ps'
  70. import { fileUrl } from '@/utils/file'
  71. import { openPrint } from '@/utils/print'
  72. import { useUserStore } from '@/store/modules/user'
  73. import PublishMarketDialog from '@/components/PublishMarketDialog.vue'
  74. import { hasPerms } from '@/utils/auth'
  75. const store = useUserStore()
  76. const rows = ref([])
  77. const loading = ref(false)
  78. const publishVisible = ref(false)
  79. const publishRow = ref(null)
  80. const previewVisible = ref(false)
  81. const previewRow = ref(null)
  82. function openPublish(row) {
  83. publishRow.value = row
  84. publishVisible.value = true
  85. }
  86. function pageSize(row) {
  87. const page = (row && row.schema && row.schema.page) || {}
  88. const w = Number(page.w) || Number(row.page_w) || 210
  89. const h = Number(page.h) || Number(row.page_h) || 297
  90. return { w, h }
  91. }
  92. function coverBox(row) {
  93. const { w, h } = pageSize(row)
  94. return { paddingTop: ((h / w) * 100) + '%' }
  95. }
  96. function previewBox(row) {
  97. const { w, h } = pageSize(row)
  98. return {
  99. '--pw': String(w),
  100. '--ph': String(h),
  101. }
  102. }
  103. function coverOf(row) {
  104. const schema = (row && row.schema) || {}
  105. return fileUrl(
  106. row.cover_url || row.cover || schema.background || schema.file_url || ''
  107. )
  108. }
  109. function openPreview(row) {
  110. if (!coverOf(row)) {
  111. return
  112. }
  113. previewRow.value = row
  114. previewVisible.value = true
  115. }
  116. function cellsOf(row) {
  117. const schema = row.schema && typeof row.schema === 'object' ? row.schema : {}
  118. const cells = Array.isArray(schema.cells) ? schema.cells : []
  119. if (cells.length) {
  120. return cells
  121. }
  122. const elements = Array.isArray(schema.elements) ? schema.elements : []
  123. return elements.filter((el) => el && (el.type === 'field' || el.role === 'fill' || el.key))
  124. }
  125. function cellLabel(cell) {
  126. return cell.label || cell.key || '空格'
  127. }
  128. function cellStyle(cell) {
  129. return {
  130. left: (cell.x || 0) + '%',
  131. top: (cell.y || 0) + '%',
  132. width: Math.max(1.2, cell.w || 4) + '%',
  133. height: Math.max(1.2, cell.h || 3) + '%',
  134. }
  135. }
  136. function sourceText(row) {
  137. if (Number(row.is_system) === 1 || Number(row.factory_id) === 0) {
  138. return '官方'
  139. }
  140. if (row.source === 'ai') {
  141. return '后台识版'
  142. }
  143. if (row.source === 'market') {
  144. return '商城购入'
  145. }
  146. return '本厂'
  147. }
  148. function paperText(row) {
  149. const page = (row.schema && row.schema.page) || {}
  150. const size = row.paper_size || page.paper_size || ''
  151. const w = page.w || row.page_w
  152. const h = page.h || row.page_h
  153. if (w && h) {
  154. return (size ? size + ' ' : '') + w + '×' + h + 'mm'
  155. }
  156. return size || '未标纸张'
  157. }
  158. async function onDel(row) {
  159. await ElMessageBox.confirm('删除后不可恢复,确定删除模板「' + row.name + '」?', '删除模板')
  160. await templateDel(row.id)
  161. ElMessage.success('已删除')
  162. load()
  163. }
  164. async function load() {
  165. loading.value = true
  166. try {
  167. const data = await templateList({ limit: 100 })
  168. const list = (data && (data.rows || data.data)) || []
  169. rows.value = Array.isArray(list) ? list : []
  170. } catch (err) {
  171. rows.value = []
  172. } finally {
  173. loading.value = false
  174. }
  175. }
  176. onMounted(load)
  177. </script>
  178. <style scoped>
  179. .tpl-grid {
  180. display: grid;
  181. grid-template-columns: repeat(5, minmax(0, 1fr));
  182. gap: 12px;
  183. }
  184. .tpl-card :deep(.el-card__body) { padding: 10px; }
  185. .cover {
  186. position: relative;
  187. margin-bottom: 8px;
  188. border-radius: 6px;
  189. overflow: hidden;
  190. background: #f3f5f8;
  191. }
  192. .cover.clickable { cursor: zoom-in; }
  193. .cover-img,
  194. .cover-empty,
  195. .cells {
  196. position: absolute;
  197. inset: 0;
  198. width: 100%;
  199. height: 100%;
  200. }
  201. .cover-img { object-fit: fill; display: block; }
  202. .cover-empty {
  203. color: #8a97a6;
  204. display: flex;
  205. align-items: center;
  206. justify-content: center;
  207. font-size: 12px;
  208. }
  209. .zoom-tip {
  210. position: absolute;
  211. right: 6px;
  212. bottom: 6px;
  213. z-index: 2;
  214. padding: 1px 6px;
  215. border-radius: 4px;
  216. background: rgba(0, 0, 0, 0.45);
  217. color: #fff;
  218. font-size: 11px;
  219. line-height: 1.6;
  220. pointer-events: none;
  221. }
  222. .cells { pointer-events: none; }
  223. .cell {
  224. position: absolute;
  225. box-sizing: border-box;
  226. border: 1px solid rgba(64, 158, 255, 0.95);
  227. background: rgba(64, 158, 255, 0.18);
  228. }
  229. h3 {
  230. margin: 0 0 4px;
  231. font-size: 14px;
  232. white-space: nowrap;
  233. overflow: hidden;
  234. text-overflow: ellipsis;
  235. }
  236. .meta { color: #7a8794; margin: 0 0 6px; font-size: 12px; }
  237. .listed { color: #e6a23c; margin: 0 0 8px; font-size: 12px; }
  238. .preview-stage {
  239. display: flex;
  240. justify-content: center;
  241. }
  242. .preview-cover {
  243. position: relative;
  244. width: min(86vw, calc(72vh * var(--pw, 148) / var(--ph, 210)));
  245. aspect-ratio: var(--pw, 148) / var(--ph, 210);
  246. max-height: 72vh;
  247. background: #fff;
  248. overflow: hidden;
  249. }
  250. @media (max-width: 1400px) {
  251. .tpl-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  252. }
  253. @media (max-width: 1100px) {
  254. .tpl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  255. }
  256. @media (max-width: 768px) {
  257. .tpl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  258. }
  259. </style>
  260. <style>
  261. .tpl-preview-dialog.el-dialog {
  262. margin: 4vh auto;
  263. }
  264. .tpl-preview-dialog .el-dialog__body {
  265. padding: 8px 16px 16px;
  266. }
  267. </style>