case-library.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. import { Component, OnInit, signal, computed } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
  4. import { RouterModule, ActivatedRoute } from '@angular/router';
  5. import * as QRCode from 'qrcode';
  6. // 定义案例接口
  7. interface CaseItem {
  8. id: string;
  9. name: string;
  10. category: string;
  11. style: string[];
  12. houseType: string;
  13. property: string;
  14. designer: string;
  15. area: number;
  16. createdAt: Date;
  17. coverImage: string;
  18. detailImages: string[];
  19. isFavorite: boolean;
  20. tags: string[];
  21. views: number;
  22. description: string;
  23. // 新增字段
  24. projectType: '工装' | '家装';
  25. subType: '平层' | '复式' | '别墅' | '自建房' | '其他';
  26. renderingLevel: '高端' | '中端';
  27. shareCount: number;
  28. favoriteCount: number;
  29. likeCount: number;
  30. conversionRate: number; // 0-100
  31. }
  32. @Component({
  33. selector: 'app-case-library',
  34. standalone: true,
  35. imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule],
  36. templateUrl: './case-library.html',
  37. styleUrls: ['./case-library.scss', '../customer-service-styles.scss']
  38. })
  39. export class CaseLibrary implements OnInit {
  40. // 当前日期
  41. currentDate = new Date();
  42. // 搜索关键词
  43. searchTerm = signal('');
  44. // 分享弹窗
  45. showShareModal = signal(false);
  46. shareLink = signal('');
  47. qrDataUrl = signal('');
  48. sharedCaseId = signal<string | null>(null);
  49. // 筛选表单
  50. filterForm: FormGroup;
  51. // 案例列表
  52. cases = signal<CaseItem[]>([]);
  53. // 筛选后的案例
  54. filteredCases = computed(() => {
  55. let result = [...this.cases()];
  56. // 应用搜索筛选
  57. if (this.searchTerm()) {
  58. const searchLower = this.searchTerm().toLowerCase();
  59. result = result.filter(caseItem =>
  60. caseItem.name.toLowerCase().includes(searchLower) ||
  61. caseItem.designer.toLowerCase().includes(searchLower) ||
  62. caseItem.description.toLowerCase().includes(searchLower) ||
  63. caseItem.tags.some(tag => tag.toLowerCase().includes(searchLower))
  64. );
  65. }
  66. // 应用表单筛选
  67. const filters = this.filterForm.value as any;
  68. if (filters.style && filters.style.length > 0) {
  69. result = result.filter(caseItem =>
  70. caseItem.style.some((s: string) => filters.style.includes(s))
  71. );
  72. }
  73. if (filters.houseType) {
  74. result = result.filter(caseItem => caseItem.houseType === filters.houseType);
  75. }
  76. if (filters.property) {
  77. result = result.filter(caseItem => caseItem.property === filters.property);
  78. }
  79. if (filters.projectType) {
  80. result = result.filter(caseItem => caseItem.projectType === filters.projectType);
  81. }
  82. if (filters.subType) {
  83. result = result.filter(caseItem => caseItem.subType === filters.subType);
  84. }
  85. if (filters.renderingLevel) {
  86. result = result.filter(caseItem => caseItem.renderingLevel === filters.renderingLevel);
  87. }
  88. if (filters.minArea) {
  89. result = result.filter(caseItem => caseItem.area >= Number(filters.minArea));
  90. }
  91. if (filters.maxArea) {
  92. result = result.filter(caseItem => caseItem.area <= Number(filters.maxArea));
  93. }
  94. if (filters.favorite) {
  95. result = result.filter(caseItem => caseItem.isFavorite);
  96. }
  97. // 排序
  98. if (filters.sortBy) {
  99. switch (filters.sortBy) {
  100. case 'views':
  101. result.sort((a, b) => b.views - a.views);
  102. break;
  103. case 'shares':
  104. result.sort((a, b) => b.shareCount - a.shareCount);
  105. break;
  106. case 'conversion':
  107. result.sort((a, b) => b.conversionRate - a.conversionRate);
  108. break;
  109. case 'createdAt':
  110. result.sort((a, b) => +b.createdAt - +a.createdAt);
  111. break;
  112. }
  113. } else {
  114. // 默认按创建时间倒序
  115. result.sort((a, b) => +b.createdAt - +a.createdAt);
  116. }
  117. return result;
  118. });
  119. // 显示筛选面板
  120. showFilterPanel = signal(false);
  121. // 当前查看的案例详情
  122. selectedCase = signal<CaseItem | null>(null);
  123. // 分页信息
  124. currentPage = signal(1);
  125. itemsPerPage = signal(12);
  126. // 分页后的案例
  127. paginatedCases = computed(() => {
  128. const startIndex = (this.currentPage() - 1) * this.itemsPerPage();
  129. return this.filteredCases().slice(startIndex, startIndex + this.itemsPerPage());
  130. });
  131. // 总页数
  132. totalPages = computed(() => {
  133. return Math.ceil(this.filteredCases().length / this.itemsPerPage());
  134. });
  135. // 筛选选项
  136. styleOptions = ['现代简约', '北欧风', '工业风', '新中式', '法式轻奢', '日式', '美式', '混搭'];
  137. houseTypeOptions = ['一室一厅', '两室一厅', '两室两厅', '三室一厅', '三室两厅', '四室两厅', '复式', '别墅', '其他'];
  138. propertyOptions = ['万科', '绿城', '保利', '龙湖', '融创', '中海', '碧桂园', '其他'];
  139. projectTypeOptions: Array<CaseItem['projectType']> = ['工装', '家装'];
  140. subTypeOptions: Array<CaseItem['subType']> = ['平层', '复式', '别墅', '自建房', '其他'];
  141. renderingLevelOptions: Array<CaseItem['renderingLevel']> = ['高端', '中端'];
  142. sortOptions = [
  143. { label: '最新上传', value: 'createdAt' },
  144. { label: '浏览最多', value: 'views' },
  145. { label: '分享最多', value: 'shares' },
  146. { label: '转化率最高', value: 'conversion' }
  147. ];
  148. constructor(private fb: FormBuilder, private route: ActivatedRoute) {
  149. // 初始化筛选表单
  150. this.filterForm = this.fb.group({
  151. style: [[]],
  152. houseType: [''],
  153. property: [''],
  154. projectType: [''],
  155. subType: [''],
  156. renderingLevel: [''],
  157. minArea: [''],
  158. maxArea: [''],
  159. favorite: [false],
  160. sortBy: ['createdAt']
  161. });
  162. }
  163. ngOnInit(): void {
  164. // 加载模拟案例数据
  165. this.loadCases();
  166. // 读取分享链接参数并打开对应案例详情
  167. this.route.queryParamMap.subscribe(params => {
  168. const caseId = params.get('case');
  169. if (caseId) {
  170. const item = this.cases().find(c => c.id === caseId);
  171. if (item) {
  172. this.viewCaseDetails(item);
  173. }
  174. }
  175. });
  176. }
  177. // 加载案例数据
  178. loadCases(): void {
  179. // 本地占位图集合
  180. const LOCAL_IMAGES = [
  181. '/assets/images/portfolio-1.svg',
  182. '/assets/images/portfolio-2.svg',
  183. '/assets/images/portfolio-3.svg',
  184. '/assets/images/portfolio-4.svg'
  185. ];
  186. const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
  187. const pick = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
  188. // 模拟API请求获取案例数据
  189. const mockCases: CaseItem[] = Array.from({ length: 24 }, (_, i) => {
  190. const cover = LOCAL_IMAGES[i % LOCAL_IMAGES.length];
  191. const details = Array.from({ length: 4 }, (_, j) => LOCAL_IMAGES[(i + j) % LOCAL_IMAGES.length]);
  192. const projectType = pick(this.projectTypeOptions);
  193. const subType = pick(this.subTypeOptions);
  194. const renderingLevel = pick(this.renderingLevelOptions);
  195. const createdAt = new Date(Date.now() - rand(0, 365) * 24 * 60 * 60 * 1000);
  196. const views = rand(100, 3000);
  197. const shareCount = rand(10, 500);
  198. const favoriteCount = rand(5, 400);
  199. const likeCount = rand(10, 800);
  200. const conversionRate = Number((Math.random() * 30 + 5).toFixed(1)); // 5% - 35%
  201. return {
  202. id: `case-${i + 1}`,
  203. name: `${pick(this.styleOptions)}风格 ${pick(this.houseTypeOptions)}设计`,
  204. category: pick(['客厅', '卧室', '厨房', '浴室', '书房', '餐厅']),
  205. style: [pick(this.styleOptions)],
  206. houseType: pick(this.houseTypeOptions),
  207. property: pick(this.propertyOptions),
  208. designer: pick(['张设计', '李设计', '王设计', '赵设计', '陈设计']),
  209. area: rand(50, 150),
  210. createdAt,
  211. coverImage: cover,
  212. detailImages: details,
  213. isFavorite: Math.random() > 0.7,
  214. tags: ['热门', '精选', '新上传', '高性价比', '业主好评'].filter(() => Math.random() > 0.5),
  215. views,
  216. description: '这是一个精美的' + pick(['现代简约', '北欧风', '新中式']) + '风格设计案例,融合了功能性与美学,为客户打造了舒适宜人的居住环境。',
  217. projectType,
  218. subType,
  219. renderingLevel,
  220. shareCount,
  221. favoriteCount,
  222. likeCount,
  223. conversionRate
  224. };
  225. });
  226. this.cases.set(mockCases);
  227. }
  228. // 切换收藏状态(同时更新收藏计数)
  229. toggleFavorite(caseId: string): void {
  230. this.cases.set(
  231. this.cases().map(caseItem => {
  232. if (caseItem.id === caseId) {
  233. const isFav = !caseItem.isFavorite;
  234. const favoriteCount = Math.max(0, caseItem.favoriteCount + (isFav ? 1 : -1));
  235. return { ...caseItem, isFavorite: isFav, favoriteCount };
  236. }
  237. return caseItem;
  238. })
  239. );
  240. }
  241. // 查看案例详情(增加浏览量)
  242. viewCaseDetails(caseItem: CaseItem): void {
  243. this.selectedCase.set(caseItem);
  244. // 增加浏览量
  245. this.cases.set(
  246. this.cases().map(item =>
  247. item.id === caseItem.id
  248. ? { ...item, views: item.views + 1 }
  249. : item
  250. )
  251. );
  252. }
  253. // 关闭案例详情
  254. closeCaseDetails(): void {
  255. this.selectedCase.set(null);
  256. }
  257. // 分享案例:生成链接、复制并展示弹窗,同时更新分享计数
  258. async shareCase(caseId: string): Promise<void> {
  259. const link = this.getShareLink(caseId);
  260. this.shareLink.set(link);
  261. this.showShareModal.set(true);
  262. this.sharedCaseId.set(caseId);
  263. // 生成二维码
  264. await this.generateQrCode(link);
  265. // 分享计数 +1
  266. this.cases.set(
  267. this.cases().map(item => item.id === caseId ? { ...item, shareCount: item.shareCount + 1 } : item)
  268. );
  269. // 尝试自动复制
  270. try {
  271. await navigator.clipboard.writeText(link);
  272. } catch {
  273. // 忽略复制失败(例如非安全上下文),用户可手动复制
  274. }
  275. }
  276. getShareLink(caseId: string): string {
  277. const base = window.location.origin;
  278. return `${base}/customer-service/case-library?case=${encodeURIComponent(caseId)}`;
  279. }
  280. async copyShareLink(): Promise<void> {
  281. const link = this.shareLink();
  282. try {
  283. await navigator.clipboard.writeText(link);
  284. alert('链接已复制到剪贴板');
  285. } catch {
  286. alert('复制失败,请手动选择链接复制');
  287. }
  288. }
  289. // 生成二维码
  290. private async generateQrCode(text: string): Promise<void> {
  291. try {
  292. const url = await QRCode.toDataURL(text, { width: 160, margin: 1 });
  293. this.qrDataUrl.set(url);
  294. } catch (e) {
  295. console.error('生成二维码失败', e);
  296. this.qrDataUrl.set('');
  297. }
  298. }
  299. downloadQrCode(): void {
  300. const dataUrl = this.qrDataUrl();
  301. if (!dataUrl) { return; }
  302. const a = document.createElement('a');
  303. const name = this.sharedCaseId() ? `${this.sharedCaseId()}-qr.png` : 'case-qr.png';
  304. a.href = dataUrl;
  305. a.download = name;
  306. document.body.appendChild(a);
  307. a.click();
  308. document.body.removeChild(a);
  309. }
  310. openShareLink(): void {
  311. const link = this.shareLink();
  312. if (link) {
  313. window.open(link, '_blank', 'noopener');
  314. }
  315. }
  316. closeShareModal(): void {
  317. this.showShareModal.set(false);
  318. this.qrDataUrl.set('');
  319. this.sharedCaseId.set(null);
  320. }
  321. // 重置筛选条件
  322. resetFilters(): void {
  323. this.filterForm.reset({
  324. style: [],
  325. houseType: '',
  326. property: '',
  327. projectType: '',
  328. subType: '',
  329. renderingLevel: '',
  330. minArea: '',
  331. maxArea: '',
  332. favorite: false,
  333. sortBy: 'createdAt'
  334. });
  335. this.searchTerm.set('');
  336. this.currentPage.set(1);
  337. }
  338. // 切换筛选面板
  339. toggleFilterPanel(): void {
  340. this.showFilterPanel.set(!this.showFilterPanel());
  341. }
  342. // 分页导航
  343. goToPage(page: number): void {
  344. if (page >= 1 && page <= this.totalPages()) {
  345. this.currentPage.set(page);
  346. }
  347. }
  348. // 上一页
  349. prevPage(): void {
  350. this.goToPage(this.currentPage() - 1);
  351. }
  352. // 下一页
  353. nextPage(): void {
  354. this.goToPage(this.currentPage() + 1);
  355. }
  356. // 格式化日期
  357. formatDate(date: Date): string {
  358. return new Date(date).toLocaleDateString('zh-CN', {
  359. month: '2-digit',
  360. day: '2-digit',
  361. year: 'numeric'
  362. });
  363. }
  364. // 智能页码生成
  365. pageNumbers = computed(() => {
  366. const pages = [] as number[];
  367. const total = this.totalPages();
  368. const current = this.currentPage();
  369. // 显示当前页及前后2页,加上第一页和最后一页
  370. const start = Math.max(1, current - 2);
  371. const end = Math.min(total, current + 2);
  372. if (start > 1) {
  373. pages.push(1);
  374. if (start > 2) {
  375. pages.push(-1); // 用-1表示省略号
  376. }
  377. }
  378. for (let i = start; i <= end; i++) {
  379. pages.push(i);
  380. }
  381. if (end < total) {
  382. if (end < total - 1) {
  383. pages.push(-1); // 用-1表示省略号
  384. }
  385. pages.push(total);
  386. }
  387. return pages;
  388. });
  389. // 格式化样式显示的辅助方法
  390. getStyleDisplay(caseItem: CaseItem | null | undefined): string {
  391. if (!caseItem || !caseItem.style) {
  392. return '';
  393. }
  394. return Array.isArray(caseItem.style) ? caseItem.style.join('、') : String(caseItem.style);
  395. }
  396. // 获取当前选中案例的样式显示
  397. getSelectedCaseStyle(): string {
  398. return this.getStyleDisplay(this.selectedCase());
  399. }
  400. // 修复 onStyleChange 方法中的类型安全问题
  401. onStyleChange(style: string, isChecked: boolean): void {
  402. const currentStyles = (this.filterForm.get('style')?.value || []) as string[];
  403. let updatedStyles: string[];
  404. if (isChecked) {
  405. // 如果勾选,则添加风格(避免重复)
  406. updatedStyles = [...new Set([...currentStyles, style])];
  407. } else {
  408. // 如果取消勾选,则移除风格
  409. updatedStyles = currentStyles.filter(s => s !== style);
  410. }
  411. this.filterForm.patchValue({ style: updatedStyles });
  412. }
  413. }