index.coffee 25 KB

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