index.coffee 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. document.head.insertAdjacentHTML('beforeend','<style type="text/tailwindcss">'+stylFns['app/pages/Admin/Products/index.styl']+'</style>')
  2. PouchDB = require 'app/utils/pouch'
  3. Papa = require 'papaparse'
  4. module.exports =
  5. name: 'AdminProducts'
  6. render: (new Function '_ctx', '_cache', renderFns['app/pages/Admin/Products/index.pug'])()
  7. data: ->
  8. products: []
  9. categories: []
  10. searchQuery: ''
  11. selectedCategory: ''
  12. selectedStatus: ''
  13. showProductModal: false
  14. showImportModal: false
  15. showCategoriesModal: false
  16. showCategoryModal: false
  17. showMassActionsModal: false
  18. showCategoryAssignModal: false
  19. showMassCategoryAssign: false
  20. showMassPriceModal: false
  21. editingProduct: null
  22. editingCategory: null
  23. selectedFile: null
  24. selectedCategoriesFile: null
  25. importing: false
  26. importingCategories: false
  27. importResults: null
  28. categoriesImportResults: null
  29. availableDomains: []
  30. categoriesActiveTab: 'list'
  31. # Mass actions data
  32. selectedProducts: []
  33. selectAll: false
  34. massCategory: ''
  35. massAllCategory: ''
  36. removeExistingCategories: false
  37. massRemoveAllCategories: false
  38. priceChangeType: 'fixed'
  39. priceChangeValue: null
  40. applyToOldPrice: false
  41. productForm:
  42. name: ''
  43. sku: ''
  44. category: ''
  45. price: 0
  46. oldPrice: 0
  47. brand: ''
  48. description: ''
  49. image: ''
  50. active: true
  51. domains: []
  52. categoryForm:
  53. name: ''
  54. slug: ''
  55. description: ''
  56. parentCategory: ''
  57. sortOrder: 0
  58. image: ''
  59. icon: ''
  60. active: true
  61. domains: []
  62. computed:
  63. filteredProducts: ->
  64. products = @products
  65. if @searchQuery
  66. query = @searchQuery.toLowerCase()
  67. products = products.filter (product) =>
  68. product.name?.toLowerCase().includes(query) or
  69. product.sku?.toLowerCase().includes(query)
  70. if @selectedCategory
  71. products = products.filter (product) =>
  72. product.category == @selectedCategory
  73. if @selectedStatus == 'active'
  74. products = products.filter (product) => product.active
  75. else if @selectedStatus == 'inactive'
  76. products = products.filter (product) => !product.active
  77. return products
  78. methods:
  79. loadProducts: ->
  80. PouchDB.queryView('admin', 'products', { include_docs: true })
  81. .then (result) =>
  82. @products = result.rows.map (row) -> row.doc
  83. .catch (error) =>
  84. debug.log 'Ошибка загрузки товаров:', error
  85. @showNotification 'Ошибка загрузки товаров', 'error'
  86. loadCategories: ->
  87. PouchDB.queryView('admin', 'categories', { include_docs: true })
  88. .then (result) =>
  89. @categories = result.rows.map (row) -> row.doc
  90. .catch (error) =>
  91. debug.log 'Ошибка загрузки категорий:', error
  92. loadDomains: ->
  93. PouchDB.queryView('admin', 'domain_settings', { include_docs: true })
  94. .then (result) =>
  95. @availableDomains = result.rows.map (row) -> row.doc
  96. .catch (error) =>
  97. debug.log 'Ошибка загрузки доменов:', error
  98. getCategoryName: (categoryId) ->
  99. category = @categories.find (cat) -> cat._id == categoryId
  100. category?.name or 'Без категории'
  101. getCategoryProductCount: (categoryId) ->
  102. @products.filter((product) -> product.category == categoryId).length
  103. # Улучшенный импорт товаров с полной обработкой CSV
  104. onFileSelect: (event) ->
  105. @selectedFile = event.target.files[0]
  106. @importResults = null
  107. importProducts: ->
  108. if !@selectedFile
  109. @showNotification 'Выберите файл для импорта', 'error'
  110. return
  111. @importing = true
  112. @importResults = null
  113. reader = new FileReader()
  114. reader.onload = (e) =>
  115. try
  116. results = Papa.parse e.target.result,
  117. header: true
  118. delimiter: ';'
  119. skipEmptyLines: true
  120. encoding: 'UTF-8'
  121. products = results.data.filter (row) =>
  122. row and row['Артикул*'] and row['Название товара'] and row['Цена, руб.*']
  123. # Обрабатываем товары последовательно для загрузки изображений
  124. @processProductsSequentially(products)
  125. .then (couchProducts) =>
  126. @importResults =
  127. success: true
  128. processed: couchProducts.length
  129. errors: []
  130. @importing = false
  131. @loadProducts()
  132. @loadCategories()
  133. @showNotification "Импортировано #{couchProducts.length} товаров"
  134. .catch (error) =>
  135. @importResults =
  136. success: false
  137. error: error.message
  138. processed: 0
  139. errors: [error.message]
  140. @importing = false
  141. @showNotification "Ошибка импорта: #{error.message}", 'error'
  142. catch error
  143. @importResults =
  144. success: false
  145. error: error.message
  146. processed: 0
  147. errors: [error.message]
  148. @importing = false
  149. @showNotification "Ошибка обработки файла: #{error.message}", 'error'
  150. reader.readAsText(@selectedFile, 'UTF-8')
  151. # Последовательная обработка товаров с загрузкой изображений
  152. processProductsSequentially: (products) ->
  153. couchProducts = []
  154. currentIndex = 0
  155. processNextProduct = =>
  156. if currentIndex >= products.length
  157. return Promise.resolve(couchProducts)
  158. product = products[currentIndex]
  159. currentIndex++
  160. @transformProductData(product, currentIndex)
  161. .then (productData) =>
  162. couchProducts.push(productData)
  163. return processNextProduct()
  164. .catch (error) =>
  165. debug.log "Ошибка обработки товара #{currentIndex}:", error
  166. # Продолжаем обработку следующих товаров даже при ошибке
  167. return processNextProduct()
  168. return processNextProduct()
  169. # Полное преобразование данных товара с загрузкой изображений
  170. transformProductData: (product, index) ->
  171. return new Promise (resolve, reject) =>
  172. try
  173. # Базовые поля
  174. productData =
  175. _id: "product:#{Date.now()}-#{index}"
  176. type: 'product'
  177. name: product['Название товара']?.trim() or 'Без названия'
  178. sku: product['Артикул*']?.trim() or "SKU-#{Date.now()}-#{index}"
  179. price: @parsePrice(product['Цена, руб.*'])
  180. active: true
  181. createdAt: new Date().toISOString()
  182. updatedAt: new Date().toISOString()
  183. domains: [window.location.hostname] # Текущий домен по умолчанию
  184. # Обработка всех полей CSV
  185. @processAllCSVFields(product, productData)
  186. # Обработка категории
  187. @processCategory(product, productData, index)
  188. .then =>
  189. # Обработка основного изображения
  190. return @processMainImage(product, productData)
  191. .then =>
  192. # Обработка дополнительных изображений
  193. return @processAdditionalImages(product, productData)
  194. .then =>
  195. # Обработка Rich-контента
  196. @processRichContent(product, productData)
  197. resolve(productData)
  198. .catch (error) =>
  199. debug.log "Ошибка обработки товара:", error
  200. # Возвращаем товар даже с ошибками обработки
  201. resolve(productData)
  202. catch error
  203. reject(error)
  204. # Парсинг цены
  205. parsePrice: (priceString) ->
  206. return 0 if !priceString
  207. try
  208. # Удаляем пробелы и заменяем запятые на точки
  209. cleanPrice = priceString.toString().replace(/\s/g, '').replace(',', '.')
  210. price = parseFloat(cleanPrice)
  211. return if isNaN(price) then 0 else price
  212. catch
  213. return 0
  214. # Обработка всех полей CSV
  215. processAllCSVFields: (product, productData) ->
  216. # Базовые поля
  217. productData.brand = product['Бренд*']?.trim() or ''
  218. productData.productType = product['Тип*']?.trim() or ''
  219. productData.weight = product['Вес товара, г']?.trim() or ''
  220. productData.volume = product['Объем, л']?.trim() or ''
  221. productData.country = product['Страна-изготовитель']?.trim() or ''
  222. productData.warranty = product['Гарантия']?.trim() or ''
  223. productData.color = product['Цвет товара']?.trim() or ''
  224. # Цены
  225. if product['Цена до скидки, руб.']
  226. productData.oldPrice = @parsePrice(product['Цена до скидки, руб.'])
  227. # Характеристики
  228. productData.attributes = {}
  229. # Технические характеристики
  230. if product['Расход, л/м2']
  231. productData.attributes.consumption = product['Расход, л/м2']
  232. if product['Время высыхания, часов']
  233. productData.attributes.dryingTime = product['Время высыхания, часов']
  234. if product['Вид краски']
  235. productData.attributes.paintType = product['Вид краски']
  236. if product['Основа краски']
  237. productData.attributes.paintBase = product['Основа краски']
  238. if product['Способ нанесения']
  239. productData.attributes.applicationMethod = product['Способ нанесения']
  240. if product['Область применения состава']
  241. productData.attributes.applicationArea = product['Область применения состава']
  242. # Мета-данные
  243. productData.tags = []
  244. if product['#Хештеги']
  245. tags = product['#Хештеги']?.split('#').filter((tag) -> tag.trim()).map((tag) -> tag.trim())
  246. productData.tags = tags or []
  247. # Статусы и флаги
  248. productData.features =
  249. installment: product['Рассрочка']?.toLowerCase() == 'да'
  250. reviewPoints: product['Баллы за отзывы']?.toLowerCase() == 'да'
  251. canBeTinted: product['Возможность колеровки']?.toLowerCase() == 'да'
  252. aerosol: product['Аэрозоль']?.toLowerCase() == 'да'
  253. # Обработка категории с проверкой дубликатов
  254. processCategory: (product, productData, index) ->
  255. return Promise.resolve() if !product['Тип*']
  256. categoryName = product['Тип*'].trim()
  257. return Promise.resolve() if !categoryName
  258. # Поиск существующей категории (регистронезависимо)
  259. existingCategory = @categories.find (cat) ->
  260. cat.name?.toLowerCase() == categoryName.toLowerCase()
  261. if existingCategory
  262. productData.category = existingCategory._id
  263. debug.log "Использована существующая категория: #{categoryName}"
  264. return Promise.resolve()
  265. else
  266. # Создание новой категории
  267. categoryId = "category:#{Date.now()}-#{index}"
  268. newCategory =
  269. _id: categoryId
  270. type: 'category'
  271. name: categoryName
  272. slug: @generateSlug(categoryName)
  273. sortOrder: @categories.length
  274. active: true
  275. createdAt: new Date().toISOString()
  276. updatedAt: new Date().toISOString()
  277. domains: [window.location.hostname]
  278. productData.category = categoryId
  279. return PouchDB.saveToRemote(newCategory)
  280. .then (result) =>
  281. debug.log "Создана новая категория: #{categoryName}"
  282. @categories.push(newCategory)
  283. return Promise.resolve()
  284. .catch (error) ->
  285. debug.log "Ошибка создания категории #{categoryName}:", error
  286. # Продолжаем без категории
  287. return Promise.resolve()
  288. # Обработка основного изображения
  289. processMainImage: (product, productData) ->
  290. return Promise.resolve() if !product['Ссылка на главное фото*']
  291. imageUrl = product['Ссылка на главное фото*'].trim()
  292. return Promise.resolve() if !imageUrl
  293. return @downloadAndStoreImage(imageUrl, productData._id, 'main.jpg')
  294. .then (attachmentInfo) =>
  295. productData.image = "/d/braer_color_shop/#{productData._id}/main.jpg"
  296. productData.attachments = productData.attachments or {}
  297. productData.attachments.main = attachmentInfo
  298. return Promise.resolve()
  299. .catch (error) =>
  300. debug.log "Ошибка загрузки основного изображения:", error
  301. # Продолжаем без изображения
  302. return Promise.resolve()
  303. # Обработка дополнительных изображений
  304. processAdditionalImages: (product, productData) ->
  305. return Promise.resolve() if !product['Ссылки на дополнительные фото']
  306. additionalImages = product['Ссылки на дополнительные фото']
  307. if typeof additionalImages == 'string'
  308. # Разделяем ссылки по переносам строк
  309. imageUrls = additionalImages.split('\n').filter((url) -> url.trim())
  310. else
  311. imageUrls = []
  312. return Promise.resolve() if imageUrls.length == 0
  313. # Обрабатываем первые 5 изображений чтобы не перегружать
  314. imagePromises = []
  315. productData.additionalImages = []
  316. productData.attachments = productData.attachments or {}
  317. for imageUrl, i in imageUrls.slice(0, 5)
  318. do (imageUrl, i) =>
  319. promise = @downloadAndStoreImage(imageUrl.trim(), productData._id, "additional-#{i}.jpg")
  320. .then (attachmentInfo) =>
  321. imagePath = "/d/braer_color_shop/#{productData._id}/additional-#{i}.jpg"
  322. productData.additionalImages.push(imagePath)
  323. productData.attachments["additional-#{i}"] = attachmentInfo
  324. return Promise.resolve()
  325. .catch (error) =>
  326. debug.log "Ошибка загрузки дополнительного изображения #{i}:", error
  327. return Promise.resolve()
  328. imagePromises.push(promise)
  329. return Promise.all(imagePromises)
  330. # Загрузка и сохранение изображения как attachment
  331. downloadAndStoreImage: (imageUrl, docId, filename) ->
  332. return new Promise (resolve, reject) =>
  333. try
  334. # Создаем XMLHttpRequest для загрузки изображения
  335. xhr = new XMLHttpRequest()
  336. xhr.open('GET', imageUrl, true)
  337. xhr.responseType = 'blob'
  338. xhr.onload = =>
  339. if xhr.status == 200
  340. blob = xhr.response
  341. # Читаем blob как ArrayBuffer
  342. reader = new FileReader()
  343. reader.onload = (e) =>
  344. arrayBuffer = e.target.result
  345. # Сохраняем как attachment в PouchDB
  346. PouchDB.putAttachment(docId, filename, arrayBuffer, blob.type)
  347. .then (result) =>
  348. resolve
  349. filename: filename
  350. contentType: blob.type
  351. size: blob.size
  352. url: "/d/braer_color_shop/#{docId}/#{filename}"
  353. .catch (error) =>
  354. reject(error)
  355. reader.readAsArrayBuffer(blob)
  356. else
  357. reject(new Error("Ошибка загрузки изображения: #{xhr.status}"))
  358. xhr.onerror = =>
  359. reject(new Error('Ошибка сети при загрузке изображения'))
  360. xhr.send()
  361. catch error
  362. reject(error)
  363. # Обработка Rich-контента JSON и преобразование в Markdown
  364. processRichContent: (product, productData) ->
  365. # Сначала пробуем Rich-контент JSON
  366. if product['Rich-контент JSON'] and product['Rich-контент JSON'].trim()
  367. try
  368. richContent = JSON.parse(product['Rich-контент JSON'])
  369. productData.description = @richContentToMarkdown(richContent)
  370. productData.originalRichContent = richContent # Сохраняем оригинал
  371. return
  372. catch error
  373. debug.log "Ошибка парсинга Rich-контента:", error
  374. # Если Rich-контент невалиден или отсутствует, используем аннотацию
  375. if product['Аннотация'] and product['Аннотация'].trim()
  376. productData.description = product['Аннотация'].trim()
  377. else
  378. productData.description = ''
  379. # Преобразование Rich-контента JSON в Markdown
  380. richContentToMarkdown: (richContent) ->
  381. return '' if !richContent
  382. try
  383. markdownParts = []
  384. if richContent.content and Array.isArray(richContent.content)
  385. for item in richContent.content
  386. markdownParts.push(@processContentItem(item))
  387. result = markdownParts.filter((part) -> part).join('\n\n')
  388. return result or 'Описание товара'
  389. catch error
  390. debug.log "Ошибка преобразования Rich-контента в Markdown:", error
  391. return JSON.stringify(richContent)
  392. # Обработка отдельного элемента контента
  393. processContentItem: (item) ->
  394. return '' if !item
  395. switch item.widgetName
  396. when 'raTextBlock'
  397. return @processTextBlock(item)
  398. when 'raHeader'
  399. return @processHeader(item)
  400. when 'raImage'
  401. return @processImage(item)
  402. when 'raList'
  403. return @processList(item)
  404. else
  405. return JSON.stringify(item)
  406. # Обработка текстового блока
  407. processTextBlock: (item) ->
  408. textParts = []
  409. # Заголовок
  410. if item.title and item.title.items
  411. titleText = @processTextItems(item.title.items)
  412. if titleText
  413. level = item.title.size or 'size3'
  414. hashes = @getHeadingLevel(level)
  415. textParts.push("#{hashes} #{titleText}")
  416. # Основной текст
  417. if item.text and item.text.items
  418. bodyText = @processTextItems(item.text.items)
  419. if bodyText
  420. textParts.push(bodyText)
  421. return textParts.join('\n\n')
  422. # Обработка заголовка
  423. processHeader: (item) ->
  424. if item.text and item.text.items
  425. headerText = @processTextItems(item.text.items)
  426. if headerText
  427. level = item.size or 'size2'
  428. hashes = @getHeadingLevel(level)
  429. return "#{hashes} #{headerText}"
  430. return ''
  431. # Обработка изображения
  432. processImage: (item) ->
  433. if item.url
  434. altText = item.alt or 'Изображение товара'
  435. return "![#{altText}](#{item.url})"
  436. return ''
  437. # Обработка списка
  438. processList: (item) ->
  439. return '' if !item.items or !Array.isArray(item.items)
  440. listItems = []
  441. for listItem in item.items
  442. if listItem.content
  443. listItems.push("- #{listItem.content}")
  444. return listItems.join('\n')
  445. # Обработка текстовых элементов
  446. processTextItems: (items) ->
  447. return '' if !items or !Array.isArray(items)
  448. textParts = []
  449. for textItem in items
  450. if textItem.type == 'text' and textItem.content
  451. content = textItem.content
  452. # Обработка форматирования
  453. if textItem.formatting
  454. if textItem.formatting.bold
  455. content = "**#{content}**"
  456. if textItem.formatting.italic
  457. content = "*#{content}*"
  458. textParts.push(content)
  459. else if textItem.type == 'br'
  460. textParts.push('\n')
  461. return textParts.join('')
  462. # Получение уровня заголовка Markdown
  463. getHeadingLevel: (size) ->
  464. switch size
  465. when 'size1', 'size5' then '#' # H1
  466. when 'size2', 'size4' then '##' # H2
  467. when 'size3' then '###' # H3
  468. else '##' # H2 по умолчанию
  469. # Генерация slug
  470. generateSlug: (text) ->
  471. return '' if !text
  472. text.toLowerCase()
  473. .replace(/\s+/g, '-')
  474. .replace(/[^\w\-]+/g, '')
  475. .replace(/\-\-+/g, '-')
  476. .replace(/^-+/, '')
  477. .replace(/-+$/, '')
  478. # Массовые действия
  479. toggleSelectAll: ->
  480. if @selectAll
  481. @selectedProducts = @filteredProducts.map (product) -> product._id
  482. else
  483. @selectedProducts = []
  484. isProductSelected: (productId) ->
  485. @selectedProducts.includes(productId)
  486. clearSelection: ->
  487. @selectedProducts = []
  488. @selectAll = false
  489. activateSelected: ->
  490. if @selectedProducts.length == 0
  491. @showNotification 'Выберите товары для активации', 'error'
  492. return
  493. promises = @selectedProducts.map (productId) =>
  494. product = @products.find (p) -> p._id == productId
  495. if product and !product.active
  496. updatedProduct = Object.assign {}, product,
  497. active: true
  498. updatedAt: new Date().toISOString()
  499. PouchDB.saveToRemote(updatedProduct)
  500. Promise.all(promises)
  501. .then (results) =>
  502. @loadProducts()
  503. @clearSelection()
  504. @showNotification "Активировано #{results.length} товаров"
  505. .catch (error) =>
  506. debug.log 'Ошибка активации товаров:', error
  507. @showNotification 'Ошибка активации товаров', 'error'
  508. deactivateSelected: ->
  509. if @selectedProducts.length == 0
  510. @showNotification 'Выберите товары для деактивации', 'error'
  511. return
  512. promises = @selectedProducts.map (productId) =>
  513. product = @products.find (p) -> p._id == productId
  514. if product and product.active
  515. updatedProduct = Object.assign {}, product,
  516. active: false
  517. updatedAt: new Date().toISOString()
  518. PouchDB.saveToRemote(updatedProduct)
  519. Promise.all(promises)
  520. .then (results) =>
  521. @loadProducts()
  522. @clearSelection()
  523. @showNotification "Деактивировано #{results.length} товаров"
  524. .catch (error) =>
  525. debug.log 'Ошибка деактивации товаров:', error
  526. @showNotification 'Ошибка деактивации товаров', 'error'
  527. deleteSelected: ->
  528. if @selectedProducts.length == 0
  529. @showNotification 'Выберите товары для удаления', 'error'
  530. return
  531. if !confirm("Вы уверены, что хотите удалить #{@selectedProducts.length} товаров?")
  532. return
  533. promises = @selectedProducts.map (productId) =>
  534. PouchDB.getDocument(productId)
  535. .then (doc) ->
  536. PouchDB.saveToRemote(Object.assign {}, doc, { _deleted: true })
  537. Promise.all(promises)
  538. .then (results) =>
  539. @loadProducts()
  540. @clearSelection()
  541. @showNotification "Удалено #{results.length} товаров"
  542. .catch (error) =>
  543. debug.log 'Ошибка удаления товаров:', error
  544. @showNotification 'Ошибка удаления товаров', 'error'
  545. showNotification: (message, type = 'success') ->
  546. if @$root.showNotification?
  547. @$root.showNotification(message, type)
  548. else
  549. debug.log("#{type}: #{message}")
  550. mounted: ->
  551. @loadProducts()
  552. @loadCategories()
  553. @loadDomains()