| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- import { Component, OnInit } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { FormsModule } from '@angular/forms';
- import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
- import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
- import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
- import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
- import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
- interface SiteData {
- id: string;
- url: string;
- name: string;
- monthlyVisits: number;
- bounceRate: number;
- avgVisitDuration: number;
- pagesPerVisit: number;
- globalRank: number;
- countryRank: number;
- topCountries: { country: string; share: number }[];
- trafficSources: { source: string; share: number }[];
- techStack: string[];
- similarSites: string[];
- selected: boolean;
- }
- @Component({
- selector: 'app-website-research',
- standalone: true,
- imports: [CommonModule, FormsModule, NeuButtonComponent, PageHeaderComponent, SummaryMetricCardComponent, EmptyStateComponent, ContentCardComponent],
- template: `
- <div class="website-research-container">
- <app-page-header
- title="独立站调研"
- eyebrow="Market Insight"
- description="分析独立站流量、排名、技术栈,支持多站对比"
- icon="🌐">
- <div pageHeaderActions>
- @if (sites.length >= 2) {
- <button class="btn-compare" [class.active]="compareMode" (click)="toggleCompareMode()">
- {{ compareMode ? '退出对比' : '⚔️ 站点对比' }}
- </button>
- }
- </div>
- </app-page-header>
- <!-- 添加站点 -->
- <div class="add-site-bar">
- <input class="site-input" type="text" placeholder="输入独立站域名"
- [(ngModel)]="newSiteUrl" (keyup.enter)="addSite()">
- <app-neu-button size="sm" color="primary" (click)="addSite()" [disabled]="!newSiteUrl.trim()">+ 添加站点</app-neu-button>
- </div>
- <!-- 概览指标 -->
- <div class="metrics-row">
- <app-summary-metric-card label="监测站点" [value]="sites.length" tone="blue" icon="🌐"></app-summary-metric-card>
- <app-summary-metric-card label="平均月访问量" [value]="formatNum(avgMonthlyVisits)" tone="default" icon="📊"></app-summary-metric-card>
- <app-summary-metric-card label="最佳排名" [value]="bestRank ? '#' + formatNum(bestRank) : '-'" tone="green" icon="🏆"></app-summary-metric-card>
- <app-summary-metric-card label="平均访问时长" [value]="avgDuration + 's'" tone="default" icon="⏱️"></app-summary-metric-card>
- </div>
- <!-- 对比模式 -->
- @if (compareMode) {
- <app-content-card title="站点对比分析" subtitle="勾选 2-4 个站点进行对比">
- <div class="compare-select-bar">
- @for (s of sites; track s.id) {
- <label class="compare-checkbox">
- <input type="checkbox" [checked]="s.selected"
- (change)="toggleSiteCompare(s)"
- [disabled]="!s.selected && selectedSites.length >= 4">
- <span>{{ s.name }}</span>
- </label>
- }
- </div>
- @if (selectedSites.length >= 2) {
- <div class="compare-table-wrap">
- <table class="compare-table">
- <thead>
- <tr>
- <th>指标</th>
- @for (s of selectedSites; track s.id) {
- <th>{{ s.name }}</th>
- }
- </tr>
- </thead>
- <tbody>
- <tr>
- <td class="metric-name">月访问量</td>
- @for (s of selectedSites; track s.id) {
- <td [class.best]="s.monthlyVisits === bestCompareVisits">{{ formatNum(s.monthlyVisits) }}</td>
- }
- </tr>
- <tr>
- <td class="metric-name">全球排名</td>
- @for (s of selectedSites; track s.id) {
- <td [class.best]="s.globalRank === bestCompareRank">#{{ formatNum(s.globalRank) }}</td>
- }
- </tr>
- <tr>
- <td class="metric-name">跳出率</td>
- @for (s of selectedSites; track s.id) {
- <td [class.best]="s.bounceRate === bestCompareBounce">{{ s.bounceRate }}%</td>
- }
- </tr>
- <tr>
- <td class="metric-name">平均访问时长</td>
- @for (s of selectedSites; track s.id) {
- <td [class.best]="s.avgVisitDuration === bestCompareDuration">{{ s.avgVisitDuration }}s</td>
- }
- </tr>
- <tr>
- <td class="metric-name">页面/访问</td>
- @for (s of selectedSites; track s.id) {
- <td [class.best]="s.pagesPerVisit === bestComparePages">{{ s.pagesPerVisit }}</td>
- }
- </tr>
- <tr>
- <td class="metric-name">主要流量来源</td>
- @for (s of selectedSites; track s.id) {
- <td>{{ s.trafficSources[0].source || '-' }} ({{ s.trafficSources[0].share || 0 }}%)</td>
- }
- </tr>
- <tr>
- <td class="metric-name">技术栈</td>
- @for (s of selectedSites; track s.id) {
- <td class="tech-cell">{{ s.techStack.slice(0, 3).join(', ') }}</td>
- }
- </tr>
- </tbody>
- </table>
- </div>
- } @else {
- <app-empty-state title="请至少勾选 2 个站点" description="选择两个或更多站点后即可进行对比" size="sm" icon="🌐"></app-empty-state>
- }
- </app-content-card>
- }
- <!-- 站点列表 -->
- @if (sites.length > 0) {
- <app-content-card title="站点详情" [hasHeaderActions]="true">
- <span contentCardActions class="section-count">{{ sites.length }} 个站点</span>
- <div class="site-cards">
- @for (s of sites; track s.id) {
- <div class="site-card" [class.expanded]="expandedSite === s.id">
- <div class="site-card-header" (click)="expandedSite = expandedSite === s.id ? '' : s.id">
- <div class="site-card-left">
- <span class="site-favicon">🌐</span>
- <div class="site-card-info">
- <strong class="site-name">{{ s.name }}</strong>
- <span class="site-url">{{ s.url }}</span>
- </div>
- </div>
- <div class="site-card-metrics">
- <span class="scm">📊 {{ formatNum(s.monthlyVisits) }}/月</span>
- <span class="scm">🏆 #{{ formatNum(s.globalRank) }}</span>
- <span class="scm">⏱️ {{ s.avgVisitDuration }}s</span>
- </div>
- <button class="btn-remove" (click)="removeSite(s.id); $event.stopPropagation()">✕</button>
- </div>
- @if (expandedSite === s.id) {
- <div class="site-card-body">
- <div class="detail-grid">
- <div class="detail-block">
- <h4>流量指标</h4>
- <div class="detail-row"><span>月访问量</span><strong>{{ formatNum(s.monthlyVisits) }}</strong></div>
- <div class="detail-row"><span>跳出率</span><strong>{{ s.bounceRate }}%</strong></div>
- <div class="detail-row"><span>平均时长</span><strong>{{ s.avgVisitDuration }}s</strong></div>
- <div class="detail-row"><span>页面/访问</span><strong>{{ s.pagesPerVisit }}</strong></div>
- </div>
- <div class="detail-block">
- <h4>排名</h4>
- <div class="detail-row"><span>全球排名</span><strong>#{{ formatNum(s.globalRank) }}</strong></div>
- <div class="detail-row"><span>国家排名</span><strong>#{{ formatNum(s.countryRank) }}</strong></div>
- </div>
- <div class="detail-block">
- <h4>流量来源</h4>
- @for (t of s.trafficSources; track t.source) {
- <div class="visual-bar-row">
- <span class="vb-label">{{ t.source }}</span>
- <div class="vb-track"><div class="vb-fill traffic" [style.width.%]="t.share"></div></div>
- <span class="vb-val">{{ t.share }}%</span>
- </div>
- }
- </div>
- <div class="detail-block">
- <h4>主要市场</h4>
- @for (c of s.topCountries; track c.country) {
- <div class="visual-bar-row">
- <span class="vb-label">{{ c.country }}</span>
- <div class="vb-track"><div class="vb-fill market" [style.width.%]="c.share"></div></div>
- <span class="vb-val">{{ c.share }}%</span>
- </div>
- }
- </div>
- <div class="detail-block">
- <h4>技术栈</h4>
- <div class="tech-tags">
- @for (t of s.techStack; track t) {
- <span class="tech-tag">{{ t }}</span>
- }
- </div>
- </div>
- <div class="detail-block">
- <h4>相似站点</h4>
- <div class="similar-list">
- @for (sim of s.similarSites; track sim) {
- <span class="similar-tag">{{ sim }}</span>
- }
- </div>
- </div>
- </div>
- </div>
- }
- </div>
- }
- </div>
- </app-content-card>
- } @else {
- <app-content-card title="独立站数据">
- <app-empty-state title="暂无独立站数据" description="输入竞品独立站域名开始调研,支持多站对比分析" icon="🌐" size="sm"></app-empty-state>
- </app-content-card>
- }
- </div>
- `,
- styleUrls: ['./website-research.component.scss']
- })
- export class WebsiteResearchComponent implements OnInit {
- sites: SiteData[] = [];
- newSiteUrl = '';
- expandedSite = '';
- compareMode = false;
- get avgMonthlyVisits(): number {
- if (!this.sites.length) return 0;
- return Math.round(this.sites.reduce((sum, s) => sum + s.monthlyVisits, 0) / this.sites.length);
- }
- get bestRank(): number {
- if (!this.sites.length) return 0;
- return Math.min(...this.sites.map(s => s.globalRank));
- }
- get avgDuration(): number {
- if (!this.sites.length) return 0;
- return Math.round(this.sites.reduce((sum, s) => sum + s.avgVisitDuration, 0) / this.sites.length);
- }
- get selectedSites(): SiteData[] {
- return this.sites.filter(s => s.selected);
- }
- get bestCompareVisits(): number { return Math.max(...this.selectedSites.map(s => s.monthlyVisits)); }
- get bestCompareRank(): number { return Math.min(...this.selectedSites.map(s => s.globalRank)); }
- get bestCompareBounce(): number { return Math.min(...this.selectedSites.map(s => s.bounceRate)); }
- get bestCompareDuration(): number { return Math.max(...this.selectedSites.map(s => s.avgVisitDuration)); }
- get bestComparePages(): number { return Math.max(...this.selectedSites.map(s => s.pagesPerVisit)); }
- constructor() {}
- ngOnInit(): void {
- this.loadSavedSites();
- }
- addSite(): void {
- const url = this.newSiteUrl.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
- if (!url) return;
- if (this.sites.some(s => s.url === url)) { this.newSiteUrl = ''; return; }
- const site = this.generateSiteData(url);
- this.sites.push(site);
- this.newSiteUrl = '';
- this.saveSites();
- }
- removeSite(id: string): void {
- this.sites = this.sites.filter(s => s.id !== id);
- this.saveSites();
- }
- toggleCompareMode(): void {
- this.compareMode = !this.compareMode;
- if (this.compareMode && this.selectedSites.length < 2) {
- this.sites.slice(0, 2).forEach(s => s.selected = true);
- }
- if (!this.compareMode) {
- this.sites.forEach(s => s.selected = false);
- }
- }
- toggleSiteCompare(site: SiteData): void {
- if (site.selected) {
- site.selected = false;
- } else if (this.selectedSites.length < 4) {
- site.selected = true;
- }
- }
- formatNum(n: number): string {
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
- if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
- return n.toString();
- }
- private generateSiteData(url: string): SiteData {
- const name = url.split('.')[0] || url;
- return {
- id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), url,
- name: name.charAt(0).toUpperCase() + name.slice(1), monthlyVisits: 0, bounceRate: 0,
- avgVisitDuration: 0, pagesPerVisit: 0, globalRank: 0, countryRank: 0, topCountries: [],
- trafficSources: [], techStack: [], similarSites: [], selected: false
- };
- }
- private pickTech(hash: number): string[] {
- const all = ['Shopify', 'WooCommerce', 'Magento', 'BigCommerce', 'React', 'Vue', 'Next.js', 'Cloudflare', 'Google Analytics', 'Klaviyo', 'Stripe', 'PayPal', 'TikTok Pixel', 'Meta Pixel', 'Hotjar'];
- const count = 3 + (hash % 5);
- const result: string[] = [];
- for (let i = 0; i < count && i < all.length; i++) {
- result.push(all[(hash + i * 3) % all.length]);
- }
- return [...new Set(result)];
- }
- private pickSimilar(hash: number, url: string): string[] {
- return [];
- }
- private hashStr(s: string): number {
- let h = 0;
- for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
- return Math.abs(h);
- }
- private saveSites(): void {
- try { localStorage.setItem('ws_sites', JSON.stringify(this.sites)); } catch {}
- }
- private loadSavedSites(): void {
- try {
- const raw = localStorage.getItem('ws_sites');
- if (raw) this.sites = JSON.parse(raw);
- } catch {}
- }
- }
|