case-library.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { Component, OnInit, OnDestroy } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule, ReactiveFormsModule } from '@angular/forms';
  4. import { FormControl } from '@angular/forms';
  5. import { Router } from '@angular/router';
  6. import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
  7. import { CaseDetailPanelComponent, Case } from './case-detail-panel.component';
  8. import { CaseService } from '../../../services/case.service';
  9. import { ProjectAutoCaseService } from '../../admin/services/project-auto-case.service';
  10. interface StatItem {
  11. id: string;
  12. name: string;
  13. shareCount: number;
  14. }
  15. interface StyleStat {
  16. style: string;
  17. count: number;
  18. }
  19. interface DesignerStat {
  20. designer: string;
  21. rate: number;
  22. }
  23. @Component({
  24. selector: 'app-case-library',
  25. standalone: true,
  26. imports: [CommonModule, FormsModule, ReactiveFormsModule, CaseDetailPanelComponent],
  27. templateUrl: './case-library.html',
  28. styleUrls: ['./case-library.scss']
  29. })
  30. export class CaseLibrary implements OnInit, OnDestroy {
  31. // 表单控件
  32. searchControl = new FormControl('');
  33. projectTypeControl = new FormControl('');
  34. spaceTypeControl = new FormControl('');
  35. renderingLevelControl = new FormControl('');
  36. styleControl = new FormControl('');
  37. areaRangeControl = new FormControl('');
  38. // 数据
  39. cases: Case[] = [];
  40. filteredCases: Case[] = [];
  41. // 统计数据
  42. topSharedCases: StatItem[] = [];
  43. favoriteStyles: StyleStat[] = [];
  44. designerRecommendations: DesignerStat[] = [];
  45. // 状态
  46. showStatsPanel = false;
  47. selectedCase: Case | null = null; // 用于详情面板
  48. selectedCaseForShare: Case | null = null; // 用于分享模态框
  49. currentPage = 1;
  50. itemsPerPage = 12; // 每页显示12个案例(3列x4行)
  51. totalPages = 1;
  52. totalCount = 0;
  53. loading = false;
  54. // 用户类型(模拟)
  55. isInternalUser = true; // 可根据实际用户权限设置
  56. // 行为追踪
  57. private pageStartTime = Date.now();
  58. private caseViewStartTimes = new Map<string, number>();
  59. constructor(
  60. private router: Router,
  61. private caseService: CaseService,
  62. private projectAutoCaseService: ProjectAutoCaseService
  63. ) {}
  64. /**
  65. * 计算封面图:优先使用交付执行上传的照片;
  66. * 过滤需求阶段图片(stage/requirements),否则回退到本地家装图片。
  67. */
  68. coverImageOf(caseItem: any): string {
  69. // 1. 检查是否有上传的交付执行图片(从 images 数组中查找真实上传的图片)
  70. const images = caseItem?.images || [];
  71. const uploadedImage = images.find((img: string) =>
  72. img &&
  73. img.startsWith('http') &&
  74. !img.includes('placeholder') &&
  75. !img.includes('unsplash.com') && // 排除默认的 Unsplash 图片
  76. !img.endsWith('.svg') &&
  77. // 只接受交付执行或其他实际上传文件,排除需求阶段图片
  78. !img.includes('/stage/requirements/') &&
  79. (img.includes('file-cloud.fmode.cn') || img.includes('storage'))
  80. );
  81. if (uploadedImage) {
  82. return uploadedImage;
  83. }
  84. // 2. 检查 coverImage 是否是真实上传的图片(同样排除需求阶段图片)
  85. const coverUrl: string = caseItem?.coverImage || '';
  86. const isRealUpload = !!(
  87. coverUrl &&
  88. coverUrl.startsWith('http') &&
  89. !coverUrl.includes('placeholder') &&
  90. !coverUrl.includes('unsplash.com') &&
  91. !coverUrl.endsWith('.svg') &&
  92. !coverUrl.includes('/stage/requirements/') &&
  93. (coverUrl.includes('file-cloud.fmode.cn') || coverUrl.includes('storage'))
  94. );
  95. if (isRealUpload) {
  96. return coverUrl;
  97. }
  98. // 3. 使用默认家装图片
  99. return '/assets/presets/家装图片.jpg';
  100. }
  101. async ngOnInit() {
  102. // 补齐可能遗漏的案例(幂等,不会重复创建)
  103. try {
  104. const result = await this.projectAutoCaseService.backfillMissingCases(10);
  105. if (result.created > 0) {
  106. console.log(`✅ 案例库补齐:新增 ${result.created}/${result.scanned}`);
  107. }
  108. } catch (e) {
  109. console.warn('⚠️ 案例库补齐失败(忽略):', e);
  110. }
  111. this.loadCases(); // loadCases 会自动调用 loadStatistics
  112. this.setupFilterListeners();
  113. this.setupBehaviorTracking();
  114. }
  115. ngOnDestroy() {
  116. // 页面销毁时的清理工作
  117. console.log('案例库页面销毁');
  118. }
  119. private setupBehaviorTracking() {
  120. // 记录页面访问(可选)
  121. console.log('案例库页面已加载');
  122. }
  123. /**
  124. * 加载案例列表 - 只显示已完成项目的案例
  125. *
  126. * 案例库显示条件(由CaseService严格验证):
  127. * ✅ 项目必须进入"售后归档"阶段
  128. * ✅ 完成以下任意一项即可进入案例库:
  129. * 1. 完成尾款结算(全部支付或部分支付)
  130. * 2. 完成客户评价(ProjectFeedback)
  131. * 3. 完成项目复盘(project.data.retrospective)
  132. */
  133. async loadCases() {
  134. this.loading = true;
  135. try {
  136. const filters = this.getFilters();
  137. const result = await this.caseService.findCases({
  138. ...filters,
  139. isPublished: undefined, // 不筛选发布状态,显示所有已完成项目
  140. page: this.currentPage,
  141. pageSize: this.itemsPerPage
  142. });
  143. console.log(`📊 从Case表查询到 ${result.cases.length} 个有效案例(已在CaseService中验证)`);
  144. // 去重:同一项目只展示一个案例(按 projectId 去重,保留最新)
  145. const uniqueMap = new Map<string, any>();
  146. for (const c of result.cases) {
  147. if (!c.projectId) {
  148. // 无 projectId 的直接保留(极少数异常数据)
  149. uniqueMap.set(`__no_project__${c.id}`, c);
  150. continue;
  151. }
  152. if (!uniqueMap.has(c.projectId)) {
  153. uniqueMap.set(c.projectId, c);
  154. } else {
  155. // 保留 publishedAt 较新的
  156. const prev = uniqueMap.get(c.projectId);
  157. const prevTime = new Date(prev.publishedAt || prev.createdAt || 0).getTime();
  158. const curTime = new Date(c.publishedAt || c.createdAt || 0).getTime();
  159. if (curTime >= prevTime) {
  160. uniqueMap.set(c.projectId, c);
  161. }
  162. }
  163. }
  164. const uniqueCases = Array.from(uniqueMap.values());
  165. this.cases = uniqueCases;
  166. this.filteredCases = uniqueCases;
  167. this.totalCount = uniqueCases.length;
  168. this.totalPages = Math.ceil(uniqueCases.length / this.itemsPerPage) || 1;
  169. console.log(`✅ 案例库已加载 ${uniqueCases.length} 个有效案例(去重后)`);
  170. // 如果没有数据,显示友好提示
  171. if (uniqueCases.length === 0) {
  172. console.log('💡 暂无已完成项目案例,请确保有项目已进入"售后归档"阶段并完成以下任意一项:');
  173. console.log(' 1. 完成尾款结算(全部支付或部分支付)');
  174. console.log(' 2. 完成客户评价(ProjectFeedback)');
  175. console.log(' 3. 完成项目复盘(project.data.retrospective)');
  176. }
  177. // 加载完案例后更新统计数据
  178. await this.loadStatistics();
  179. } catch (error) {
  180. console.error('❌ 加载案例列表失败:', error);
  181. // 即使出错也设置为空数组,避免页面崩溃
  182. this.cases = [];
  183. this.filteredCases = [];
  184. this.totalCount = 0;
  185. this.totalPages = 1;
  186. this.showToast('加载案例列表失败,请检查数据库连接', 'error');
  187. } finally {
  188. this.loading = false;
  189. }
  190. }
  191. /**
  192. * 加载统计数据
  193. */
  194. async loadStatistics() {
  195. try {
  196. // Top 5 分享案例
  197. const sortedByShare = [...this.cases]
  198. .filter(c => c.shareCount && c.shareCount > 0)
  199. .sort((a, b) => (b.shareCount || 0) - (a.shareCount || 0))
  200. .slice(0, 5);
  201. this.topSharedCases = sortedByShare.map(c => ({
  202. id: c.id,
  203. name: c.name,
  204. shareCount: c.shareCount || 0
  205. }));
  206. // 客户最喜欢风格 - 根据收藏数统计
  207. const styleStats: { [key: string]: number } = {};
  208. this.cases.forEach(c => {
  209. const tags = c.tag || c.styleTags || [];
  210. tags.forEach(tag => {
  211. styleStats[tag] = (styleStats[tag] || 0) + (c.favoriteCount || 0);
  212. });
  213. });
  214. this.favoriteStyles = Object.entries(styleStats)
  215. .sort((a, b) => b[1] - a[1])
  216. .slice(0, 5)
  217. .map(([style, count]) => ({ style, count }));
  218. // 设计师作品推荐率 - 简化计算
  219. const designerStats: { [key: string]: { total: number; recommended: number } } = {};
  220. this.cases.forEach(c => {
  221. const designer = c.designer || '未知设计师';
  222. if (!designerStats[designer]) {
  223. designerStats[designer] = { total: 0, recommended: 0 };
  224. }
  225. designerStats[designer].total++;
  226. if (c.isExcellent) {
  227. designerStats[designer].recommended++;
  228. }
  229. });
  230. this.designerRecommendations = Object.entries(designerStats)
  231. .map(([designer, stats]) => ({
  232. designer,
  233. rate: stats.total > 0 ? Math.round((stats.recommended / stats.total) * 100) : 0
  234. }))
  235. .sort((a, b) => b.rate - a.rate)
  236. .slice(0, 5);
  237. console.log('✅ 统计数据已加载:', {
  238. topSharedCases: this.topSharedCases.length,
  239. favoriteStyles: this.favoriteStyles.length,
  240. designerRecommendations: this.designerRecommendations.length
  241. });
  242. } catch (error) {
  243. console.error('❌ 加载统计数据失败:', error);
  244. this.topSharedCases = [];
  245. this.favoriteStyles = [];
  246. this.designerRecommendations = [];
  247. }
  248. }
  249. /**
  250. * 获取当前筛选条件
  251. */
  252. private getFilters() {
  253. const filters: any = {};
  254. const searchKeyword = this.searchControl.value?.trim();
  255. if (searchKeyword) {
  256. filters.searchKeyword = searchKeyword;
  257. }
  258. const projectType = this.projectTypeControl.value;
  259. if (projectType) {
  260. filters.projectType = projectType;
  261. }
  262. const spaceType = this.spaceTypeControl.value;
  263. if (spaceType) {
  264. filters.spaceType = spaceType;
  265. }
  266. const renderingLevel = this.renderingLevelControl.value;
  267. if (renderingLevel) {
  268. filters.renderingLevel = renderingLevel;
  269. }
  270. const style = this.styleControl.value;
  271. if (style) {
  272. // 使用tags数组进行筛选
  273. filters.tags = [style];
  274. }
  275. const areaRange = this.areaRangeControl.value;
  276. if (areaRange) {
  277. // 解析面积范围 "0-50" -> { min: 0, max: 50 }
  278. const [min, max] = areaRange.split('-').map(Number);
  279. filters.areaRange = { min, max: max || undefined };
  280. }
  281. return filters;
  282. }
  283. /**
  284. * 获取本月新增案例数
  285. */
  286. get monthlyNewCases(): number {
  287. const now = new Date();
  288. const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
  289. return this.cases.filter(c => {
  290. const createdAt = new Date(c.createdAt);
  291. return createdAt >= thisMonthStart;
  292. }).length;
  293. }
  294. private setupFilterListeners() {
  295. // 搜索框防抖
  296. this.searchControl.valueChanges.pipe(
  297. debounceTime(300),
  298. distinctUntilChanged()
  299. ).subscribe(() => this.applyFilters());
  300. // 其他筛选条件变化
  301. [
  302. this.projectTypeControl,
  303. this.spaceTypeControl,
  304. this.renderingLevelControl,
  305. this.styleControl,
  306. this.areaRangeControl
  307. ].forEach(control => {
  308. control.valueChanges.subscribe(() => this.applyFilters());
  309. });
  310. }
  311. applyFilters() {
  312. this.currentPage = 1;
  313. this.loadCases();
  314. }
  315. resetFilters() {
  316. this.searchControl.setValue('');
  317. this.projectTypeControl.setValue('');
  318. this.spaceTypeControl.setValue('');
  319. this.renderingLevelControl.setValue('');
  320. this.styleControl.setValue('');
  321. this.areaRangeControl.setValue('');
  322. this.currentPage = 1;
  323. this.loadCases();
  324. }
  325. get paginatedCases(): Case[] {
  326. return this.filteredCases;
  327. }
  328. nextPage() {
  329. if (this.currentPage < this.totalPages) {
  330. this.currentPage++;
  331. this.loadCases();
  332. }
  333. }
  334. previousPage() {
  335. if (this.currentPage > 1) {
  336. this.currentPage--;
  337. this.loadCases();
  338. }
  339. }
  340. async viewCaseDetail(caseItem: Case) {
  341. // 记录案例查看开始时间
  342. this.caseViewStartTimes.set(caseItem.id, Date.now());
  343. try {
  344. // 从数据库获取完整的案例详情
  345. const detail = await this.caseService.getCase(caseItem.id);
  346. // 设置当前选中的案例以显示详情面板
  347. this.selectedCase = detail;
  348. } catch (error) {
  349. console.error('查看案例详情失败:', error);
  350. this.showToast('查看案例详情失败', 'error');
  351. }
  352. }
  353. // 跳转到独立的案例详情页面
  354. async navigateToCaseDetail(caseItem: Case) {
  355. // 记录案例查看开始时间
  356. this.caseViewStartTimes.set(caseItem.id, Date.now());
  357. // 跳转到独立的案例详情页面
  358. this.router.navigate(['/customer-service/case-detail', caseItem.id]);
  359. }
  360. closeCaseDetail() {
  361. this.selectedCase = null;
  362. }
  363. async toggleFavorite(caseItem: Case) {
  364. try {
  365. // 暂时只切换前端状态,后续可添加收藏功能
  366. caseItem.isFavorite = !caseItem.isFavorite;
  367. if (caseItem.isFavorite) {
  368. this.showToast('已收藏该案例', 'success');
  369. } else {
  370. this.showToast('已取消收藏', 'info');
  371. }
  372. } catch (error) {
  373. console.error('切换收藏状态失败:', error);
  374. this.showToast('操作失败,请重试', 'error');
  375. }
  376. }
  377. async shareCase(caseItem: Case) {
  378. this.selectedCaseForShare = caseItem;
  379. }
  380. closeShareModal() {
  381. this.selectedCaseForShare = null;
  382. }
  383. generateQRCode(caseItem: Case): string {
  384. // 实际项目中应使用二维码生成库,如 qrcode.js
  385. const qrData = this.generateShareLink(caseItem);
  386. // 这里返回一个模拟的二维码图片
  387. const svgContent = `
  388. <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
  389. <rect width="200" height="200" fill="white"/>
  390. <rect x="20" y="20" width="160" height="160" fill="black"/>
  391. <rect x="30" y="30" width="140" height="140" fill="white"/>
  392. <text x="100" y="105" text-anchor="middle" font-size="12" fill="black">案例二维码</text>
  393. <text x="100" y="125" text-anchor="middle" font-size="10" fill="gray">${caseItem.name}</text>
  394. </svg>
  395. `;
  396. // 使用 encodeURIComponent 来正确处理SVG内容
  397. const encodedSVG = encodeURIComponent(svgContent);
  398. return `data:image/svg+xml;charset=utf-8,${encodedSVG}`;
  399. }
  400. generateShareLink(caseItem: Case): string {
  401. return `${window.location.origin}/customer-service/case-detail/${caseItem.id}?from=share&designer=${encodeURIComponent(caseItem.designer)}`;
  402. }
  403. async copyShareLink() {
  404. if (this.selectedCaseForShare) {
  405. try {
  406. const shareUrl = this.generateShareLink(this.selectedCaseForShare);
  407. await navigator.clipboard.writeText(shareUrl);
  408. this.showToast('链接已复制到剪贴板,可直接分享给客户!', 'success');
  409. } catch (err) {
  410. this.showToast('复制失败,请手动复制链接', 'error');
  411. console.error('复制链接失败:', err);
  412. }
  413. }
  414. }
  415. async shareToWeCom() {
  416. if (this.selectedCaseForShare) {
  417. try {
  418. const shareUrl = this.generateShareLink(this.selectedCaseForShare);
  419. // 实际项目中应集成企业微信分享SDK
  420. const shareData = {
  421. title: `${this.selectedCaseForShare.name} - ${this.selectedCaseForShare.designer}设计作品`,
  422. description: `${this.selectedCaseForShare.projectType} | ${this.selectedCaseForShare.spaceType} | ${this.selectedCaseForShare.area}㎡`,
  423. link: shareUrl,
  424. imgUrl: this.selectedCaseForShare.coverImage
  425. };
  426. // 模拟企业微信分享
  427. console.log('分享到企业微信:', shareData);
  428. this.showToast('已调用企业微信分享', 'success');
  429. this.closeShareModal();
  430. } catch (error) {
  431. console.error('分享失败:', error);
  432. this.showToast('分享失败,请重试', 'error');
  433. }
  434. }
  435. }
  436. showStatistics() {
  437. this.showStatsPanel = !this.showStatsPanel;
  438. if (this.showStatsPanel) {
  439. this.loadStatistics();
  440. }
  441. }
  442. // ========== 案例已完成项目展示功能(只读) ==========
  443. // 移除了手动创建、编辑、删除功能
  444. // 案例由项目自动创建,只提供查看和分享功能
  445. // 显示提示消息
  446. private showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
  447. // 实际项目中应使用专业的Toast组件
  448. const toast = document.createElement('div');
  449. toast.className = `toast toast-${type}`;
  450. toast.textContent = message;
  451. toast.style.cssText = `
  452. position: fixed;
  453. top: 20px;
  454. right: 20px;
  455. padding: 12px 20px;
  456. border-radius: 8px;
  457. color: white;
  458. font-weight: 500;
  459. z-index: 10000;
  460. animation: slideIn 0.3s ease;
  461. background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6'};
  462. `;
  463. document.body.appendChild(toast);
  464. setTimeout(() => {
  465. toast.style.animation = 'slideOut 0.3s ease';
  466. setTimeout(() => document.body.removeChild(toast), 300);
  467. }, 3000);
  468. }
  469. }