size-chart.service.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { Injectable } from '@angular/core';
  2. import { Observable, BehaviorSubject, of } from 'rxjs';
  3. import { map, tap, catchError } from 'rxjs/operators';
  4. import { DataStoreService } from '../../../app/core/services/data-store.service';
  5. // ── Interfaces ──────────────────────────────────────────────────────────────
  6. export interface SizeRow {
  7. id?: string;
  8. sizeName: string;
  9. usaSize: string;
  10. bust: string;
  11. waist: string;
  12. hip: string;
  13. torso: string;
  14. cup: string;
  15. }
  16. export interface SizeTip {
  17. type: 'warn' | 'info';
  18. text: string;
  19. }
  20. export interface SizeChart {
  21. id?: string;
  22. name: string;
  23. category: string;
  24. unit: string;
  25. note: string;
  26. rows: SizeRow[];
  27. tips: SizeTip[];
  28. }
  29. // ── Built-in default ─────────────────────────────────────────────────────────
  30. export const DEFAULT_SIZE_CHART: SizeChart = {
  31. name: 'US 女装标准尺码(泳装/瑜伽裤/运动上衣)',
  32. category: 'swimwear',
  33. unit: 'in',
  34. note: '美国站标准尺码对照表(泳装/瑜伽裤/运动上衣,单位:英寸)。当某 SKU 尺码不合退货占比 > 25% 时,请核对实测三围与下表偏差。',
  35. rows: [
  36. { sizeName: 'XS', usaSize: '0-2', bust: '32-34', waist: '25-26.5', hip: '35-36.5', torso: '57.5-59.5', cup: '30A/B, 32A' },
  37. { sizeName: 'S', usaSize: '4-6', bust: '34.5-36', waist: '27-28.5', hip: '37-38.5', torso: '59.5-61.5', cup: '32B/C, 34B' },
  38. { sizeName: 'M', usaSize: '8-10', bust: '36.5-38', waist: '29-31', hip: '39-41', torso: '61.5-63.5', cup: '34C/D, 36B/C'},
  39. { sizeName: 'L', usaSize: '12-14', bust: '38.5-40.5',waist: '32-34', hip: '42-44', torso: '63.5-65.5', cup: '36D, 38C/D' },
  40. { sizeName: 'XL', usaSize: '16-18', bust: '41-43', waist: '35-37', hip: '45-47', torso: '65.5-67.5', cup: '38DD, 40C/D' },
  41. { sizeName: 'XXL', usaSize: '20-22', bust: '43.5-45.5',waist: '37.5-39', hip: '47.5-49.5',torso: '67.5-69.5', cup: '40DD, 42C/D' }
  42. ],
  43. tips: [
  44. { type: 'warn', text: '退货预警:若「尺码偏小」退货 > 25%,优先检查胸围实测 vs 标准偏差是否超 0.5 in' },
  45. { type: 'info', text: '版型选型:高腰全包(安全感+防透)→ 对照腰围标准;常规下装 → 对照臀围标准' },
  46. { type: 'info', text: '新品迭代:每批新品上市后 30 天追踪尺码退货率,偏差 > 0.5 in 触发改版' }
  47. ]
  48. };
  49. const PARSE_CLASS = 'SizeChartKnowledge';
  50. // ── Service ──────────────────────────────────────────────────────────────────
  51. /**
  52. * @description 尺码表知识库服务(可供全局任意页面注入使用)
  53. *
  54. * 数据存储在 Parse `SizeChartKnowledge` 表;无数据时自动降级到内置默认表。
  55. *
  56. * 典型用法:
  57. * constructor(private sizeChartService: SizeChartService) {}
  58. * this.sizeChartService.getCharts().subscribe(charts => ...);
  59. * this.sizeChartService.saveChart(chart).subscribe(saved => ...);
  60. */
  61. @Injectable({ providedIn: 'root' })
  62. export class SizeChartService {
  63. private cache$ = new BehaviorSubject<SizeChart[]>([]);
  64. constructor(private dataStore: DataStoreService) {}
  65. // ── 订阅缓存(实时更新)─────────────────────────────
  66. get charts$(): Observable<SizeChart[]> {
  67. return this.cache$.asObservable();
  68. }
  69. // ── 查询全部尺码表(加载后自动写入缓存)─────────────
  70. getCharts(): Observable<SizeChart[]> {
  71. return this.dataStore.query(PARSE_CLASS, {}, { limit: 100 }).pipe(
  72. map(rows => rows.length ? rows.map(r => this.fromRow(r)) : [DEFAULT_SIZE_CHART]),
  73. tap(charts => this.cache$.next(charts)),
  74. catchError(() => {
  75. const fallback = [DEFAULT_SIZE_CHART];
  76. this.cache$.next(fallback);
  77. return of(fallback);
  78. })
  79. );
  80. }
  81. // ── 取单张表 ──────────────────────────────────────
  82. getChart(id: string): Observable<SizeChart | null> {
  83. return this.dataStore.query(PARSE_CLASS, { objectId: id }, { limit: 1 }).pipe(
  84. map(rows => rows.length ? this.fromRow(rows[0]) : null),
  85. catchError(() => of(null))
  86. );
  87. }
  88. // ── 新建或更新 ────────────────────────────────────
  89. saveChart(chart: SizeChart): Observable<SizeChart> {
  90. const payload: Record<string, any> = {
  91. name: chart.name,
  92. category: chart.category,
  93. unit: chart.unit,
  94. note: chart.note,
  95. rows: JSON.stringify(chart.rows),
  96. tips: JSON.stringify(chart.tips)
  97. };
  98. if (chart.id) payload['objectId'] = chart.id;
  99. return this.dataStore.save(PARSE_CLASS, payload, chart.id ? 'objectId' : undefined).pipe(
  100. map((saved: any) => {
  101. const result: SizeChart = { ...chart, id: saved?.objectId ?? chart.id };
  102. this.updateCache(result);
  103. return result;
  104. }),
  105. catchError(() => of(chart))
  106. );
  107. }
  108. // ── 删除 ─────────────────────────────────────────
  109. deleteChart(id: string): Observable<void> {
  110. return this.dataStore.delete(PARSE_CLASS, id).pipe(
  111. tap(() => this.cache$.next(this.cache$.value.filter(c => c.id !== id))),
  112. map(() => void 0),
  113. catchError(() => of(void 0))
  114. );
  115. }
  116. // ── CSV 导入(返回解析后的行数组,由调用方决定写入哪张表)─
  117. parseCsv(csvText: string): SizeRow[] {
  118. const lines = csvText.trim().split('\n').filter(l => l.trim());
  119. if (lines.length < 2) return [];
  120. const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
  121. return lines.slice(1).map(line => {
  122. const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
  123. const cell = (key: string[]) => {
  124. for (const k of key) {
  125. const i = headers.indexOf(k);
  126. if (i >= 0 && cols[i]) return cols[i];
  127. }
  128. return '';
  129. };
  130. return {
  131. sizeName: cell(['size', 'sizename', 'size_name', '尺码']),
  132. usaSize: cell(['usa', 'usasize', 'usa_size', 'usa码']),
  133. bust: cell(['bust', '胸围']),
  134. waist: cell(['waist', '腰围']),
  135. hip: cell(['hip', '臀围']),
  136. torso: cell(['torso', '躯干']),
  137. cup: cell(['cup', '罩杯'])
  138. };
  139. }).filter(r => r.sizeName);
  140. }
  141. // ── 获取默认表(其他页面可直接取同步引用)───────────
  142. getDefaultChart(): SizeChart {
  143. return { ...DEFAULT_SIZE_CHART };
  144. }
  145. // ── 工具:生成空行 ────────────────────────────────
  146. newEmptyRow(): SizeRow {
  147. return { sizeName: '', usaSize: '', bust: '', waist: '', hip: '', torso: '', cup: '' };
  148. }
  149. // ── 深克隆一张表(用于编辑前创建副本)────────────────
  150. cloneChart(chart: SizeChart): SizeChart {
  151. return JSON.parse(JSON.stringify(chart));
  152. }
  153. // ── 生成 AI 可读的尺码知识库上下文块 ────────────────
  154. /**
  155. * 将尺码表数组序列化为 Markdown 格式的知识块,可直接嵌入 AI Prompt。
  156. * 如果传入空数组,自动降级到内置默认表。
  157. */
  158. formatForAI(charts: SizeChart[]): string {
  159. const list = charts.length ? charts : [DEFAULT_SIZE_CHART];
  160. const sections = list.map(chart => {
  161. const unit = chart.unit || 'in';
  162. const header = `### 【${chart.name}】(单位:${unit},品类:${chart.category || '通用'})`;
  163. const note = chart.note ? `> ${chart.note}\n` : '';
  164. // 表格
  165. const tableHeader = `| 尺码 | 美码 | 胸围 | 腰围 | 臀围 | 躯干长 | 罩杯 |`;
  166. const tableSep = `|------|------|------|------|------|--------|------|`;
  167. const tableRows = (chart.rows || []).map(r =>
  168. `| ${r.sizeName} | ${r.usaSize} | ${r.bust} | ${r.waist} | ${r.hip} | ${r.torso} | ${r.cup} |`
  169. ).join('\n');
  170. // 使用提示
  171. const tips = (chart.tips || []).map(t =>
  172. `- ${t.type === 'warn' ? '⚠️' : 'ℹ️'} ${t.text}`
  173. ).join('\n');
  174. return [header, note, tableHeader, tableSep, tableRows, tips ? '\n**使用提示:**\n' + tips : '']
  175. .filter(s => s.trim()).join('\n');
  176. });
  177. return [
  178. '---',
  179. '## 📐 尺码知识库(请在涉及尺码、版型、退货相关优化时优先参照此标准)',
  180. ...sections,
  181. '---'
  182. ].join('\n\n');
  183. }
  184. // ── 内部 ─────────────────────────────────────────
  185. private fromRow(r: any): SizeChart {
  186. return {
  187. id: r.objectId,
  188. name: r.name ?? '',
  189. category: r.category ?? '',
  190. unit: r.unit ?? 'in',
  191. note: r.note ?? '',
  192. rows: this.safeJson(r.rows, []),
  193. tips: this.safeJson(r.tips, [])
  194. };
  195. }
  196. private safeJson(val: any, fallback: any): any {
  197. if (!val) return fallback;
  198. if (typeof val === 'object') return val;
  199. try { return JSON.parse(val); } catch { return fallback; }
  200. }
  201. private updateCache(chart: SizeChart): void {
  202. const list = [...this.cache$.value];
  203. const idx = list.findIndex(c => c.id === chart.id);
  204. idx >= 0 ? (list[idx] = chart) : list.push(chart);
  205. this.cache$.next(list);
  206. }
  207. }