website-research.component.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { Component, OnInit } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
  5. import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
  6. import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
  7. import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
  8. import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
  9. interface SiteData {
  10. id: string;
  11. url: string;
  12. name: string;
  13. monthlyVisits: number;
  14. bounceRate: number;
  15. avgVisitDuration: number;
  16. pagesPerVisit: number;
  17. globalRank: number;
  18. countryRank: number;
  19. topCountries: { country: string; share: number }[];
  20. trafficSources: { source: string; share: number }[];
  21. techStack: string[];
  22. similarSites: string[];
  23. selected: boolean;
  24. }
  25. @Component({
  26. selector: 'app-website-research',
  27. standalone: true,
  28. imports: [CommonModule, FormsModule, NeuButtonComponent, PageHeaderComponent, SummaryMetricCardComponent, EmptyStateComponent, ContentCardComponent],
  29. template: `
  30. <div class="website-research-container">
  31. <app-page-header
  32. title="独立站调研"
  33. eyebrow="Market Insight"
  34. description="分析独立站流量、排名、技术栈,支持多站对比"
  35. icon="🌐">
  36. <div pageHeaderActions>
  37. @if (sites.length >= 2) {
  38. <button class="btn-compare" [class.active]="compareMode" (click)="toggleCompareMode()">
  39. {{ compareMode ? '退出对比' : '⚔️ 站点对比' }}
  40. </button>
  41. }
  42. </div>
  43. </app-page-header>
  44. <!-- 添加站点 -->
  45. <div class="add-site-bar">
  46. <input class="site-input" type="text" placeholder="输入独立站域名"
  47. [(ngModel)]="newSiteUrl" (keyup.enter)="addSite()">
  48. <app-neu-button size="sm" color="primary" (click)="addSite()" [disabled]="!newSiteUrl.trim()">+ 添加站点</app-neu-button>
  49. </div>
  50. <!-- 概览指标 -->
  51. <div class="metrics-row">
  52. <app-summary-metric-card label="监测站点" [value]="sites.length" tone="blue" icon="🌐"></app-summary-metric-card>
  53. <app-summary-metric-card label="平均月访问量" [value]="formatNum(avgMonthlyVisits)" tone="default" icon="📊"></app-summary-metric-card>
  54. <app-summary-metric-card label="最佳排名" [value]="bestRank ? '#' + formatNum(bestRank) : '-'" tone="green" icon="🏆"></app-summary-metric-card>
  55. <app-summary-metric-card label="平均访问时长" [value]="avgDuration + 's'" tone="default" icon="⏱️"></app-summary-metric-card>
  56. </div>
  57. <!-- 对比模式 -->
  58. @if (compareMode) {
  59. <app-content-card title="站点对比分析" subtitle="勾选 2-4 个站点进行对比">
  60. <div class="compare-select-bar">
  61. @for (s of sites; track s.id) {
  62. <label class="compare-checkbox">
  63. <input type="checkbox" [checked]="s.selected"
  64. (change)="toggleSiteCompare(s)"
  65. [disabled]="!s.selected && selectedSites.length >= 4">
  66. <span>{{ s.name }}</span>
  67. </label>
  68. }
  69. </div>
  70. @if (selectedSites.length >= 2) {
  71. <div class="compare-table-wrap">
  72. <table class="compare-table">
  73. <thead>
  74. <tr>
  75. <th>指标</th>
  76. @for (s of selectedSites; track s.id) {
  77. <th>{{ s.name }}</th>
  78. }
  79. </tr>
  80. </thead>
  81. <tbody>
  82. <tr>
  83. <td class="metric-name">月访问量</td>
  84. @for (s of selectedSites; track s.id) {
  85. <td [class.best]="s.monthlyVisits === bestCompareVisits">{{ formatNum(s.monthlyVisits) }}</td>
  86. }
  87. </tr>
  88. <tr>
  89. <td class="metric-name">全球排名</td>
  90. @for (s of selectedSites; track s.id) {
  91. <td [class.best]="s.globalRank === bestCompareRank">#{{ formatNum(s.globalRank) }}</td>
  92. }
  93. </tr>
  94. <tr>
  95. <td class="metric-name">跳出率</td>
  96. @for (s of selectedSites; track s.id) {
  97. <td [class.best]="s.bounceRate === bestCompareBounce">{{ s.bounceRate }}%</td>
  98. }
  99. </tr>
  100. <tr>
  101. <td class="metric-name">平均访问时长</td>
  102. @for (s of selectedSites; track s.id) {
  103. <td [class.best]="s.avgVisitDuration === bestCompareDuration">{{ s.avgVisitDuration }}s</td>
  104. }
  105. </tr>
  106. <tr>
  107. <td class="metric-name">页面/访问</td>
  108. @for (s of selectedSites; track s.id) {
  109. <td [class.best]="s.pagesPerVisit === bestComparePages">{{ s.pagesPerVisit }}</td>
  110. }
  111. </tr>
  112. <tr>
  113. <td class="metric-name">主要流量来源</td>
  114. @for (s of selectedSites; track s.id) {
  115. <td>{{ s.trafficSources[0].source || '-' }} ({{ s.trafficSources[0].share || 0 }}%)</td>
  116. }
  117. </tr>
  118. <tr>
  119. <td class="metric-name">技术栈</td>
  120. @for (s of selectedSites; track s.id) {
  121. <td class="tech-cell">{{ s.techStack.slice(0, 3).join(', ') }}</td>
  122. }
  123. </tr>
  124. </tbody>
  125. </table>
  126. </div>
  127. } @else {
  128. <app-empty-state title="请至少勾选 2 个站点" description="选择两个或更多站点后即可进行对比" size="sm" icon="🌐"></app-empty-state>
  129. }
  130. </app-content-card>
  131. }
  132. <!-- 站点列表 -->
  133. @if (sites.length > 0) {
  134. <app-content-card title="站点详情" [hasHeaderActions]="true">
  135. <span contentCardActions class="section-count">{{ sites.length }} 个站点</span>
  136. <div class="site-cards">
  137. @for (s of sites; track s.id) {
  138. <div class="site-card" [class.expanded]="expandedSite === s.id">
  139. <div class="site-card-header" (click)="expandedSite = expandedSite === s.id ? '' : s.id">
  140. <div class="site-card-left">
  141. <span class="site-favicon">🌐</span>
  142. <div class="site-card-info">
  143. <strong class="site-name">{{ s.name }}</strong>
  144. <span class="site-url">{{ s.url }}</span>
  145. </div>
  146. </div>
  147. <div class="site-card-metrics">
  148. <span class="scm">📊 {{ formatNum(s.monthlyVisits) }}/月</span>
  149. <span class="scm">🏆 #{{ formatNum(s.globalRank) }}</span>
  150. <span class="scm">⏱️ {{ s.avgVisitDuration }}s</span>
  151. </div>
  152. <button class="btn-remove" (click)="removeSite(s.id); $event.stopPropagation()">✕</button>
  153. </div>
  154. @if (expandedSite === s.id) {
  155. <div class="site-card-body">
  156. <div class="detail-grid">
  157. <div class="detail-block">
  158. <h4>流量指标</h4>
  159. <div class="detail-row"><span>月访问量</span><strong>{{ formatNum(s.monthlyVisits) }}</strong></div>
  160. <div class="detail-row"><span>跳出率</span><strong>{{ s.bounceRate }}%</strong></div>
  161. <div class="detail-row"><span>平均时长</span><strong>{{ s.avgVisitDuration }}s</strong></div>
  162. <div class="detail-row"><span>页面/访问</span><strong>{{ s.pagesPerVisit }}</strong></div>
  163. </div>
  164. <div class="detail-block">
  165. <h4>排名</h4>
  166. <div class="detail-row"><span>全球排名</span><strong>#{{ formatNum(s.globalRank) }}</strong></div>
  167. <div class="detail-row"><span>国家排名</span><strong>#{{ formatNum(s.countryRank) }}</strong></div>
  168. </div>
  169. <div class="detail-block">
  170. <h4>流量来源</h4>
  171. @for (t of s.trafficSources; track t.source) {
  172. <div class="visual-bar-row">
  173. <span class="vb-label">{{ t.source }}</span>
  174. <div class="vb-track"><div class="vb-fill traffic" [style.width.%]="t.share"></div></div>
  175. <span class="vb-val">{{ t.share }}%</span>
  176. </div>
  177. }
  178. </div>
  179. <div class="detail-block">
  180. <h4>主要市场</h4>
  181. @for (c of s.topCountries; track c.country) {
  182. <div class="visual-bar-row">
  183. <span class="vb-label">{{ c.country }}</span>
  184. <div class="vb-track"><div class="vb-fill market" [style.width.%]="c.share"></div></div>
  185. <span class="vb-val">{{ c.share }}%</span>
  186. </div>
  187. }
  188. </div>
  189. <div class="detail-block">
  190. <h4>技术栈</h4>
  191. <div class="tech-tags">
  192. @for (t of s.techStack; track t) {
  193. <span class="tech-tag">{{ t }}</span>
  194. }
  195. </div>
  196. </div>
  197. <div class="detail-block">
  198. <h4>相似站点</h4>
  199. <div class="similar-list">
  200. @for (sim of s.similarSites; track sim) {
  201. <span class="similar-tag">{{ sim }}</span>
  202. }
  203. </div>
  204. </div>
  205. </div>
  206. </div>
  207. }
  208. </div>
  209. }
  210. </div>
  211. </app-content-card>
  212. } @else {
  213. <app-content-card title="独立站数据">
  214. <app-empty-state title="暂无独立站数据" description="输入竞品独立站域名开始调研,支持多站对比分析" icon="🌐" size="sm"></app-empty-state>
  215. </app-content-card>
  216. }
  217. </div>
  218. `,
  219. styleUrls: ['./website-research.component.scss']
  220. })
  221. export class WebsiteResearchComponent implements OnInit {
  222. sites: SiteData[] = [];
  223. newSiteUrl = '';
  224. expandedSite = '';
  225. compareMode = false;
  226. get avgMonthlyVisits(): number {
  227. if (!this.sites.length) return 0;
  228. return Math.round(this.sites.reduce((sum, s) => sum + s.monthlyVisits, 0) / this.sites.length);
  229. }
  230. get bestRank(): number {
  231. if (!this.sites.length) return 0;
  232. return Math.min(...this.sites.map(s => s.globalRank));
  233. }
  234. get avgDuration(): number {
  235. if (!this.sites.length) return 0;
  236. return Math.round(this.sites.reduce((sum, s) => sum + s.avgVisitDuration, 0) / this.sites.length);
  237. }
  238. get selectedSites(): SiteData[] {
  239. return this.sites.filter(s => s.selected);
  240. }
  241. get bestCompareVisits(): number { return Math.max(...this.selectedSites.map(s => s.monthlyVisits)); }
  242. get bestCompareRank(): number { return Math.min(...this.selectedSites.map(s => s.globalRank)); }
  243. get bestCompareBounce(): number { return Math.min(...this.selectedSites.map(s => s.bounceRate)); }
  244. get bestCompareDuration(): number { return Math.max(...this.selectedSites.map(s => s.avgVisitDuration)); }
  245. get bestComparePages(): number { return Math.max(...this.selectedSites.map(s => s.pagesPerVisit)); }
  246. constructor() {}
  247. ngOnInit(): void {
  248. this.loadSavedSites();
  249. }
  250. addSite(): void {
  251. const url = this.newSiteUrl.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
  252. if (!url) return;
  253. if (this.sites.some(s => s.url === url)) { this.newSiteUrl = ''; return; }
  254. const site = this.generateSiteData(url);
  255. this.sites.push(site);
  256. this.newSiteUrl = '';
  257. this.saveSites();
  258. }
  259. removeSite(id: string): void {
  260. this.sites = this.sites.filter(s => s.id !== id);
  261. this.saveSites();
  262. }
  263. toggleCompareMode(): void {
  264. this.compareMode = !this.compareMode;
  265. if (this.compareMode && this.selectedSites.length < 2) {
  266. this.sites.slice(0, 2).forEach(s => s.selected = true);
  267. }
  268. if (!this.compareMode) {
  269. this.sites.forEach(s => s.selected = false);
  270. }
  271. }
  272. toggleSiteCompare(site: SiteData): void {
  273. if (site.selected) {
  274. site.selected = false;
  275. } else if (this.selectedSites.length < 4) {
  276. site.selected = true;
  277. }
  278. }
  279. formatNum(n: number): string {
  280. if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
  281. if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
  282. return n.toString();
  283. }
  284. private generateSiteData(url: string): SiteData {
  285. const name = url.split('.')[0] || url;
  286. return {
  287. id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), url,
  288. name: name.charAt(0).toUpperCase() + name.slice(1), monthlyVisits: 0, bounceRate: 0,
  289. avgVisitDuration: 0, pagesPerVisit: 0, globalRank: 0, countryRank: 0, topCountries: [],
  290. trafficSources: [], techStack: [], similarSites: [], selected: false
  291. };
  292. }
  293. private pickTech(hash: number): string[] {
  294. const all = ['Shopify', 'WooCommerce', 'Magento', 'BigCommerce', 'React', 'Vue', 'Next.js', 'Cloudflare', 'Google Analytics', 'Klaviyo', 'Stripe', 'PayPal', 'TikTok Pixel', 'Meta Pixel', 'Hotjar'];
  295. const count = 3 + (hash % 5);
  296. const result: string[] = [];
  297. for (let i = 0; i < count && i < all.length; i++) {
  298. result.push(all[(hash + i * 3) % all.length]);
  299. }
  300. return [...new Set(result)];
  301. }
  302. private pickSimilar(hash: number, url: string): string[] {
  303. return [];
  304. }
  305. private hashStr(s: string): number {
  306. let h = 0;
  307. for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  308. return Math.abs(h);
  309. }
  310. private saveSites(): void {
  311. try { localStorage.setItem('ws_sites', JSON.stringify(this.sites)); } catch {}
  312. }
  313. private loadSavedSites(): void {
  314. try {
  315. const raw = localStorage.getItem('ws_sites');
  316. if (raw) this.sites = JSON.parse(raw);
  317. } catch {}
  318. }
  319. }