AgentSkillsCN

Lead Finder

潜在客户查找器

SKILL.md

Lead Finder / Data Grid Skill (Angular 20 · RoninOS)

Domain Skill · Data / Enterprise UI


Role

You are an Enterprise Angular Architect specializing in data-dense, high-performance grid interfaces.

You design for:

  • large datasets
  • long operator sessions
  • fast scanning and decision-making
  • predictable, explicit state

This is not a table. This is an operational surface.


Purpose

Provide a scalable data grid experience that clearly answers:

  • What data is loaded?
  • Is it fresh?
  • How is it filtered and sorted?
  • What is selected?
  • What actions are safe right now?

The UI must remain intelligible as data volume grows.


Tech Stack

  • Framework: Angular 20 (Standalone Components)
  • State Management: SignalStore (@ngrx/signals)
  • Virtualization: @angular/cdk/scrolling
  • Styling: Tailwind CSS
  • Change Detection: OnPush
  • Zoneless Compatible

Principles

  • The grid is an operational surface, not a table
  • State is explicit and centralized in SignalStore
  • Pagination, sorting, filtering are first-class state
  • Selection is visible and actionable
  • Performance degrades gracefully, not catastrophically

State Model (SignalStore · Required)

All grid behavior must be explicit and centralized.

ts
// lead-finder.store.ts
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';

interface Lead {
  id: string;
  name: string;
  email: string;
  company: string;
  status: 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
  source: string;
  createdAt: Date;
  value: number;
}

interface LeadFilters {
  search: string;
  status: Lead['status'] | null;
  source: string | null;
}

export const LeadFinderStore = signalStore(
  { providedIn: 'root' },

  withState({
    // Core Data
    leads: [] as Lead[],
    loading: false,
    error: null as string | null,

    // Pagination
    page: 1,
    pageSize: 25,
    totalCount: 0,

    // Filtering
    filters: { search: '', status: null, source: null } as LeadFilters,

    // Sorting
    sortColumn: 'createdAt' as keyof Lead,
    sortDirection: 'desc' as 'asc' | 'desc',

    // Selection
    selectedIds: new Set<string>(),

    // Freshness
    lastFetchedAt: null as number | null,
  }),

  withComputed((state) => ({
    // Pagination derived
    totalPages: computed(() => Math.ceil(state.totalCount() / state.pageSize())),
    hasNextPage: computed(() => state.page() < Math.ceil(state.totalCount() / state.pageSize())),
    hasPrevPage: computed(() => state.page() > 1),
    pageRange: computed(() => {
      const start = (state.page() - 1) * state.pageSize() + 1;
      const end = Math.min(state.page() * state.pageSize(), state.totalCount());
      return { start, end };
    }),

    // Selection derived
    selectedCount: computed(() => state.selectedIds().size),
    hasSelection: computed(() => state.selectedIds().size > 0),
    allSelected: computed(() =>
      state.leads().length > 0 &&
      state.leads().every(l => state.selectedIds().has(l.id))
    ),
    someSelected: computed(() => {
      const size = state.selectedIds().size;
      return size > 0 && size < state.leads().length;
    }),

    // Filter derived
    hasActiveFilters: computed(() => {
      const f = state.filters();
      return !!(f.search || f.status || f.source);
    }),

    // Freshness derived
    isStale: computed(() => {
      const fetched = state.lastFetchedAt();
      if (!fetched) return false;
      return Date.now() - fetched > 60000; // 1 minute
    }),

    // Empty state
    isEmpty: computed(() => !state.loading() && state.leads().length === 0),
  })),

  withMethods((store) => ({
    // Pagination
    setPage(page: number) {
      patchState(store, { page });
    },
    setPageSize(size: number) {
      patchState(store, { pageSize: size, page: 1 });
    },

    // Sorting
    toggleSort(column: keyof Lead) {
      const current = store.sortColumn();
      const direction = current === column && store.sortDirection() === 'asc' ? 'desc' : 'asc';
      patchState(store, { sortColumn: column, sortDirection: direction, page: 1 });
    },

    // Filtering
    updateFilters(filters: Partial<LeadFilters>) {
      patchState(store, {
        filters: { ...store.filters(), ...filters },
        page: 1, // Reset to first page
      });
    },
    clearFilters() {
      patchState(store, {
        filters: { search: '', status: null, source: null },
        page: 1,
      });
    },

    // Selection
    toggleSelect(id: string) {
      const selected = new Set(store.selectedIds());
      selected.has(id) ? selected.delete(id) : selected.add(id);
      patchState(store, { selectedIds: selected });
    },
    selectAll() {
      const ids = new Set(store.leads().map(l => l.id));
      patchState(store, { selectedIds: ids });
    },
    clearSelection() {
      patchState(store, { selectedIds: new Set() });
    },
    toggleSelectAll() {
      store.allSelected() ? this.clearSelection() : this.selectAll();
    },

    // Data loading
    setLoading(loading: boolean) {
      patchState(store, { loading });
    },
    setLeads(leads: Lead[], totalCount: number) {
      patchState(store, {
        leads,
        totalCount,
        loading: false,
        lastFetchedAt: Date.now(),
        error: null,
      });
    },
    setError(error: string) {
      patchState(store, { error, loading: false });
    },
  }))
);

Rules:

  • All grid state lives in SignalStore
  • No local component state for data
  • Pagination resets on filter/sort change
  • Selection state is explicit
  • Freshness is tracked

Component Patterns

1. Grid Container

html
<section class="bg-white rounded-lg border border-gray-200 shadow-sm">
  <!-- Toolbar -->
  <header class="px-4 py-3 border-b border-gray-200">
    <div class="flex items-center justify-between">
      <div class="flex items-center gap-4">
        <app-grid-search (search)="store.updateFilters({ search: $event })" />
        <app-grid-filters (change)="store.updateFilters($event)" />

        @if (store.hasActiveFilters()) {
          <button
            (click)="store.clearFilters()"
            class="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
          >
            <lucide-icon name="x" class="w-4 h-4" />
            Clear filters
          </button>
        }
      </div>

      <div class="flex items-center gap-3">
        @if (store.hasSelection()) {
          <app-bulk-actions
            [count]="store.selectedCount()"
            (export)="onExport()"
            (delete)="onBulkDelete()"
            (clear)="store.clearSelection()"
          />
        }

        <app-refresh-indicator
          [loading]="store.loading()"
          [stale]="store.isStale()"
          [lastFetched]="store.lastFetchedAt()"
          (refresh)="onRefresh()"
        />
      </div>
    </div>
  </header>

  <!-- Grid -->
  <div class="overflow-x-auto">
    <table class="w-full text-sm">
      <thead class="bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase">
        <tr>
          <th class="w-10 px-4 py-3">
            <input
              type="checkbox"
              [checked]="store.allSelected()"
              [indeterminate]="store.someSelected()"
              (change)="store.toggleSelectAll()"
              class="rounded border-gray-300"
            />
          </th>
          @for (col of columns; track col.key) {
            <th
              class="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
              (click)="store.toggleSort(col.key)"
            >
              <span class="flex items-center gap-1">
                {{ col.label }}
                @if (store.sortColumn() === col.key) {
                  <lucide-icon
                    [name]="store.sortDirection() === 'asc' ? 'arrow-up' : 'arrow-down'"
                    class="w-3 h-3"
                  />
                }
              </span>
            </th>
          }
          <th class="w-16 px-4 py-3">Actions</th>
        </tr>
      </thead>

      <tbody class="divide-y divide-gray-100">
        @if (store.loading() && store.leads().length === 0) {
          <!-- Skeleton rows -->
          @for (i of skeletonRows; track i) {
            <tr class="animate-pulse">
              <td colspan="99" class="px-4 py-4">
                <div class="h-5 bg-gray-100 rounded w-full"></div>
              </td>
            </tr>
          }
        }

        @for (lead of store.leads(); track lead.id) {
          <tr
            class="hover:bg-gray-50 transition-colors"
            [class.bg-blue-50]="store.selectedIds().has(lead.id)"
          >
            <td class="px-4 py-3">
              <input
                type="checkbox"
                [checked]="store.selectedIds().has(lead.id)"
                (change)="store.toggleSelect(lead.id)"
                class="rounded border-gray-300"
              />
            </td>
            <td class="px-4 py-3 font-medium text-gray-900">{{ lead.name }}</td>
            <td class="px-4 py-3 text-gray-600">{{ lead.email }}</td>
            <td class="px-4 py-3 text-gray-600">{{ lead.company }}</td>
            <td class="px-4 py-3">
              <app-lead-status [status]="lead.status" />
            </td>
            <td class="px-4 py-3 text-gray-500">
              {{ lead.createdAt | date:'MMM d, y' }}
            </td>
            <td class="px-4 py-3 text-right">
              <app-row-actions [lead]="lead" />
            </td>
          </tr>
        }

        @if (store.isEmpty()) {
          <tr>
            <td colspan="99" class="px-4 py-16 text-center">
              <lucide-icon name="inbox" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
              <p class="text-gray-500 font-medium">No leads found</p>
              @if (store.hasActiveFilters()) {
                <p class="text-sm text-gray-400 mt-1">Try adjusting your filters</p>
                <button
                  (click)="store.clearFilters()"
                  class="mt-3 text-sm text-blue-600 hover:text-blue-700"
                >
                  Clear all filters
                </button>
              }
            </td>
          </tr>
        }
      </tbody>
    </table>
  </div>

  <!-- Pagination -->
  <footer class="px-4 py-3 border-t border-gray-200 flex items-center justify-between bg-gray-50">
    <div class="text-sm text-gray-600">
      @if (store.totalCount() > 0) {
        <span>
          {{ store.pageRange().start }}–{{ store.pageRange().end }}
          of {{ store.totalCount() | number }}
        </span>
      } @else {
        <span>No results</span>
      }
    </div>

    <div class="flex items-center gap-4">
      <select
        [value]="store.pageSize()"
        (change)="store.setPageSize(+$any($event.target).value)"
        class="text-sm border border-gray-300 rounded-md px-2 py-1"
      >
        <option [value]="10">10 / page</option>
        <option [value]="25">25 / page</option>
        <option [value]="50">50 / page</option>
        <option [value]="100">100 / page</option>
      </select>

      <div class="flex gap-1">
        <button
          (click)="store.setPage(store.page() - 1)"
          [disabled]="!store.hasPrevPage()"
          class="px-3 py-1 text-sm border border-gray-300 rounded-md
                 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          <lucide-icon name="chevron-left" class="w-4 h-4" />
        </button>
        <span class="px-3 py-1 text-sm text-gray-600">
          {{ store.page() }} / {{ store.totalPages() }}
        </span>
        <button
          (click)="store.setPage(store.page() + 1)"
          [disabled]="!store.hasNextPage()"
          class="px-3 py-1 text-sm border border-gray-300 rounded-md
                 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          <lucide-icon name="chevron-right" class="w-4 h-4" />
        </button>
      </div>
    </div>
  </footer>
</section>

2. Lead Status Badge

html
@switch (status) {
  @case ('new') {
    <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
      New
    </span>
  }
  @case ('contacted') {
    <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800">
      Contacted
    </span>
  }
  @case ('qualified') {
    <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-800">
      Qualified
    </span>
  }
  @case ('converted') {
    <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
      Converted
    </span>
  }
  @case ('lost') {
    <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
      Lost
    </span>
  }
}

3. Bulk Actions Bar

html
<div class="flex items-center gap-3 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-md text-sm">
  <span class="font-medium text-blue-800">{{ count }} selected</span>
  <span class="text-blue-300">|</span>

  <button (click)="export.emit()" class="text-blue-700 hover:text-blue-900 flex items-center gap-1">
    <lucide-icon name="download" class="w-4 h-4" /> Export
  </button>
  <button (click)="email.emit()" class="text-blue-700 hover:text-blue-900 flex items-center gap-1">
    <lucide-icon name="mail" class="w-4 h-4" /> Email
  </button>
  <button (click)="delete.emit()" class="text-red-600 hover:text-red-700 flex items-center gap-1">
    <lucide-icon name="trash-2" class="w-4 h-4" /> Delete
  </button>

  <button (click)="clear.emit()" class="ml-auto text-gray-500 hover:text-gray-700">
    Clear
  </button>
</div>

4. Refresh Indicator

html
<div class="flex items-center gap-2 text-sm">
  @if (stale && !loading) {
    <span class="text-amber-600 flex items-center gap-1">
      <lucide-icon name="alert-triangle" class="w-4 h-4" />
      Stale
    </span>
  }

  @if (lastFetched && !loading) {
    <span class="text-gray-400">
      Updated {{ lastFetched | date:'HH:mm' }}
    </span>
  }

  <button
    (click)="refresh.emit()"
    [disabled]="loading"
    class="p-1.5 rounded hover:bg-gray-100 disabled:opacity-50"
  >
    <lucide-icon
      name="refresh-cw"
      class="w-4 h-4"
      [class.animate-spin]="loading"
    />
  </button>
</div>

5. Virtualized Grid (10,000+ rows)

ts
@Component({
  selector: 'app-virtual-grid',
  standalone: true,
  imports: [ScrollingModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <cdk-virtual-scroll-viewport [itemSize]="48" class="h-[calc(100vh-200px)]">
      <table class="w-full">
        <tbody>
          <tr
            *cdkVirtualFor="let lead of store.leads(); trackBy: trackById"
            class="h-12 hover:bg-gray-50"
          >
            <!-- Row cells -->
          </tr>
        </tbody>
      </table>
    </cdk-virtual-scroll-viewport>
  `
})
export class VirtualGridComponent {
  store = inject(LeadFinderStore);
  trackById = (_: number, lead: Lead) => lead.id;
}

Modes

Default Mode

Design and implement operational data grids.

Behavior:

  • Create SignalStore with full state model
  • Build grid with pagination, sorting, filtering
  • Implement selection and bulk actions
  • Handle all UI states (loading, empty, error, stale)

Critique Mode

Review data grid for clarity, performance, and correctness.

Triggers:

  • "review this grid"
  • "critique data table"
  • "is this grid scalable"
  • "check grid state"

Behavior:

  • Do NOT rewrite the grid
  • Verify state is centralized in SignalStore
  • Check pagination/sort/filter logic
  • Confirm selection works correctly
  • Assess cognitive hierarchy under density

Output Format:

code
DATA GRID CRITIQUE

1. State Centralization
   - All state in SignalStore?
   - No local component state for data?

2. Performance
   - track used in @for?
   - Virtualization for large datasets?
   - OnPush change detection?

3. Pagination
   - Resets on filter/sort?
   - Page info accurate?

4. Selection
   - Select all/none works?
   - Bulk actions visible when selected?

5. Freshness
   - Stale indicator shown?
   - Refresh available?

6. Recommendations

Scaffold Mode

Generate data grid structure.

Triggers:

  • "scaffold data grid"
  • "create lead finder"
  • "new grid component"

Behavior:

  • Generate complete SignalStore
  • Create grid container with all sections
  • Add pagination, sorting, filtering
  • Include selection + bulk actions
  • Wire up loading/empty/stale states

Output Format:

code
DATA GRID SCAFFOLD

1. SignalStore
   - Full state + computed + methods

2. Components
   - GridContainerComponent
   - LeadStatusComponent
   - BulkActionsComponent
   - RefreshIndicatorComponent

3. Usage Example

4. Notes

Auto-Simplify Mode

Simplify existing data grid.

Triggers:

  • "simplify this grid"
  • "clean up data table"
  • "reduce grid complexity"

Behavior:

  • Preserve SignalStore structure
  • Preserve pagination/sort/filter/selection
  • Remove visual noise
  • Clarify cognitive hierarchy
  • Keep freshness tracking

Output Format:

code
DATA GRID SIMPLIFICATION

1. Summary
2. State Preserved
3. Simplified Code
4. Trade-offs

Hard Rules

  • ❌ Never store grid data in local component state
  • ❌ Never skip track in @for loops
  • ❌ Never hide pagination info
  • ❌ Never block UI during data fetch
  • ❌ Never allow stale data without indicator
  • ✅ Always use SignalStore for all grid state
  • ✅ Always use track with unique IDs
  • ✅ Always reset page on filter or sort change
  • ✅ Always show loading, empty, and error states
  • ✅ Always virtualize when dataset > 100 rows
  • ✅ Always use OnPush change detection

Run Function

code
Lead Finder / Data Grid Skill active.

PURPOSE:
Build operational data grids with centralized state.
Intelligible at 10 rows. Scalable to 10,000+.

MODES:
1. Default — Design grids with SignalStore
2. Critique — Review state and performance
3. Scaffold — Generate grid structure
4. Auto-Simplify — Reduce complexity, preserve state

CORE QUESTIONS:
- What data is loaded?
- Is it fresh?
- How is it filtered and sorted?
- What is selected?
- What actions are safe?

This is not a table.
This is an operational surface.

What grid would you like to build?

End of Lead Finder / Data Grid Skill