Просмотр исходного кода

feat: add store operations management

彭峰 1 месяц назад
Родитель
Сommit
fe5fe571a5

Разница между файлами не показана из-за своего большого размера
+ 12 - 0
scripts/deploy-admin-functions.mjs


+ 1 - 0
src/app/admin/admin.constants.ts

@@ -16,6 +16,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
   { label: '运营总览', items: [{ label: '工作台', icon: 'layout-dashboard', route: '/admin/dashboard' }] },
   { label: '核心运营', items: [
     { label: '会员管理', icon: 'users', route: '/admin/members' },
+    { label: '门店管理', icon: 'store', route: '/admin/stores' },
     { label: '排课中心', icon: 'calendar-days', route: '/admin/schedule' },
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },

+ 45 - 0
src/app/admin/admin.models.ts

@@ -317,6 +317,51 @@ export interface CoachSummary extends Record<string, unknown> {
   readOnly?: boolean;
 }
 
+export interface StoreSummary extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  username: string;
+  storeName: string;
+  contactName: string;
+  mobile: string;
+  mobileMasked: string;
+  parentStoreId: number;
+  parentStoreName: string;
+  memberCount: number;
+  coachCount: number;
+  todayAppointments: number;
+  totalAppointments: number;
+  lastAppointmentAt: string;
+  registeredAt: string;
+  lastLoginAt: string;
+  loginCount: number;
+  status: 'active' | 'disabled';
+  statusLabel: string;
+  updatedAt: string;
+}
+
+export interface StoreOption extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  displayName: string;
+}
+
+export interface StorePage extends OperationsPage<StoreSummary> {
+  canManage: boolean;
+  summary: {
+    total: number;
+    active: number;
+    disabled: number;
+    members: number;
+    coaches: number;
+    todayAppointments: number;
+  };
+  parents: StoreOption[];
+  liveSourceAvailable?: boolean;
+  liveSourceMessage?: string;
+  sync?: OperationsSyncIndicator;
+}
+
 export interface CoachStoreOption extends Record<string, unknown> {
   objectId: string;
   userId: number;

+ 1 - 0
src/app/admin/admin.routes.ts

@@ -8,6 +8,7 @@ const operationsChildren: Routes = [
   { path: 'users/:objectId', redirectTo: 'members/:objectId' },
   { path: 'members', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
   { path: 'members/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
+  { path: 'stores', loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'schedule', loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },

+ 6 - 0
src/app/admin/operations.service.spec.ts

@@ -39,6 +39,12 @@ describe('OperationsService', () => {
     expect(functions.operations).toHaveBeenCalledWith('ops/schedule/calendar', { dateFrom: '2026-08-20', dateTo: '2026-08-26', filters: { coachId: 88 } });
   });
 
+  it('loads stores through the dedicated operations projection', async () => {
+    functions.operations.and.resolveTo({ page: 1, pageSize: 20, total: 0, items: [], parents: [], summary: {} });
+    await service.stores(1, 20, '南昌', { status: 'active' });
+    expect(functions.operations).toHaveBeenCalledWith('ops/stores/list', { page: 1, pageSize: 20, search: '南昌', filters: { status: 'active' } });
+  });
+
   it('adds concurrency and idempotency metadata to every mutation', async () => {
     functions.operations.and.resolveTo({ success: true });
     await service.mutate('ops/appointments/update', 'appointment-1', '运营改期', { coachId: 3 }, '2026-08-20T08:00:00.000Z', 'stable-key');

+ 5 - 1
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -52,6 +52,10 @@ export class OperationsService {
     return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters });
   }
 
+  stores(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<StorePage> {
+    return this.functions.operations<StorePage>('ops/stores/list', { page, pageSize, search, filters });
+  }
+
   coachDetail(objectId: string): Promise<CoachDetailData> {
     return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId });
   }

+ 62 - 0
src/app/admin/pages/operations-stores.component.html

@@ -0,0 +1,62 @@
+<header class="page-header">
+  <div><p>STORE OPERATIONS</p><h1>门店管理</h1><span>统一管理门店账号、上下级关系、会员、陪练与排课情况</span></div>
+  <div class="header-actions">
+    <button class="ui-button secondary" type="button" (click)="load(page()?.page||1)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button>
+    @if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增门店</button>}
+  </div>
+</header>
+
+<section class="summary-grid" aria-label="门店运营摘要">
+  <article><span>门店总数</span><strong>{{page()?.summary?.total||0}}</strong><small>{{page()?.summary?.active||0}} 家正常运营</small></article>
+  <article><span>归属会员</span><strong>{{page()?.summary?.members||0}}</strong><small>按门店关系归集</small></article>
+  <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与工资统计</small></article>
+  <article><span>今日排课</span><strong>{{page()?.summary?.todayAppointments||0}}</strong><small>已归属门店的课程</small></article>
+  <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>停用后无法登录</small></article>
+</section>
+
+<section class="toolbar">
+  <label class="grow"><span>搜索门店</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="门店名、联系人、账号、手机号或门店ID"/></label>
+  <label><span>账号状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="all">全部</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
+  <button class="ui-button primary" type="button" (click)="load(1)"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button>
+  <button class="ui-button secondary" type="button" (click)="reset()">重置</button>
+</section>
+
+@if(message()){<p class="notice" role="status">{{message()}}</p>}
+@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
+@if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
+
+<section class="content-card">
+  <header><div><lucide-icon [img]="icons.Building2" [size]="19"/><strong>门店名单</strong></div><span>{{page()?.total||0}} 家 · {{page()?.sync?.label||'已读取当前数据'}}</span></header>
+  <div class="table-scroll"><table class="data-table"><thead><tr><th>门店</th><th>上级门店</th><th>联系方式</th><th>会员</th><th>陪练</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>最后登录</th><th>状态</th><th>操作</th></tr></thead><tbody>
+    @for(row of page()?.items||[];track row.objectId){<tr>
+      <td><strong>{{row.storeName}}</strong><small>{{row.username}} · ID {{row.userId}}</small></td>
+      <td>{{row.parentStoreName||'总店/未归属'}}</td>
+      <td>{{row.contactName||'未设置联系人'}}<small>{{row.mobile||row.mobileMasked||'未留手机号'}}</small></td>
+      <td><strong>{{row.memberCount}}</strong> 人</td><td><strong>{{row.coachCount}}</strong> 人</td><td>{{row.todayAppointments}} 节</td><td>{{row.totalAppointments}} 节</td>
+      <td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd HH:mm'):'暂无'}}</td><td>{{row.lastLoginAt?(row.lastLoginAt|date:'yyyy-MM-dd HH:mm'):'从未登录'}}</td>
+      <td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td>
+      <td><div class="row-actions">@if(page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑门店" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除门店" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}@else{<span class="read-only">只读</span>}</div></td>
+    </tr>}@empty{<tr><td colspan="11"><div class="empty">没有符合条件的门店</div></td></tr>}
+  </tbody></table></div>
+  <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>
+</section>
+
+@if(drawerOpen()){
+  <div class="backdrop" (click)="closeDrawer()"></div>
+  <aside class="drawer store-editor" role="dialog" aria-modal="true" [attr.aria-label]="editing()?'编辑门店':'新增门店'">
+    <header><div><p>STORE ACCOUNT</p><h2>{{editing()?'编辑门店':'新增门店'}}</h2><span>维护门店账号、联系方式和上下级关系</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="19"/></button></header>
+    @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+    <div class="form-grid store-form">
+      <label><span>登录账号 <em>*</em></span><input autocomplete="username" [ngModel]="username()" (ngModelChange)="username.set($event)" placeholder="至少 2 位"/></label>
+      <label><span>{{editing()?'重置密码':'初始密码'}} <em [class.optional]="editing()">{{editing()?'选填':'*'}}</em></span><input type="password" autocomplete="new-password" [ngModel]="password()" (ngModelChange)="password.set($event)" [placeholder]="editing()?'留空则不修改':'至少 8 位'"/></label>
+      <label><span>门店名称 <em>*</em></span><input [ngModel]="storeName()" (ngModelChange)="storeName.set($event)" placeholder="例如:南昌总店"/></label>
+      <label><span>联系人</span><input [ngModel]="contactName()" (ngModelChange)="contactName.set($event)" placeholder="门店负责人姓名"/></label>
+      <label><span>手机号码</span><input inputmode="numeric" maxlength="11" [ngModel]="mobile()" (ngModelChange)="mobile.set($event)" placeholder="11 位手机号码"/></label>
+      <label><span>上级门店</span><select [ngModel]="parentStoreId()" (ngModelChange)="parentStoreId.set(+$event)"><option [ngValue]="0">无(总店)</option>@for(store of availableParents();track store.userId){<option [ngValue]="store.userId">{{store.displayName}}</option>}</select></label>
+      <label><span>账号状态</span><select [ngModel]="disabled()" (ngModelChange)="disabled.set($event)"><option [ngValue]="false">正常使用</option><option [ngValue]="true">停用账号</option></select></label>
+      <label class="wide"><span>操作原因 <em>*</em></span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="用于操作审计,例如:新店开业、资料更正或门店停业"></textarea></label>
+    </div>
+    @if(deleteArmed()){<p class="delete-warning">只有没有会员、陪练或下级门店的空门店才能删除。存在业务关系时,请改为“停用账号”。</p>}
+    <footer>@if(editing()){<button class="ui-button danger push-left" type="button" (click)="remove()" [disabled]="saving()"><lucide-icon [img]="icons.Trash2" [size]="16"/>{{deleteArmed()?'确认删除':'删除门店'}}</button>}<button class="ui-button secondary" type="button" (click)="closeDrawer()">取消</button><button class="ui-button primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'保存资料'}}</button></footer>
+  </aside>
+}

+ 48 - 0
src/app/admin/pages/operations-stores.component.scss

@@ -0,0 +1,48 @@
+:host { --control-height: 42px; }
+
+.summary-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 14px;
+  margin-bottom: 18px;
+}
+
+.summary-grid article {
+  min-width: 0;
+  padding: 18px 20px;
+  border: 1px solid #dce7e3;
+  border-radius: 17px;
+  background: #fff;
+  box-shadow: 0 8px 25px rgba(31, 67, 55, .04);
+}
+
+.summary-grid span, .summary-grid small { display: block; color: #7c8e87; }
+.summary-grid span { font-size: 12px; font-weight: 750; }
+.summary-grid strong { display: block; margin: 7px 0 2px; color: #21352e; font-size: 29px; line-height: 1; }
+.summary-grid small { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+
+.data-table { min-width: 1360px; }
+.row-actions { min-width: 82px; flex-wrap: nowrap; }
+.store-editor { width: min(640px, 100%); }
+.store-editor header p { margin: 0; color: #258b68; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
+.store-editor header h2 { margin: 5px 0 7px; }
+.store-editor header span { color: #788882; font-size: 13px; }
+.store-form label > span { display: flex; align-items: center; gap: 5px; }
+.store-form em { color: #bd4545; font-style: normal; }
+.store-form em.optional { color: #8a9792; font-size: 11px; font-weight: 500; }
+.store-form textarea { min-height: 92px; resize: vertical; }
+.notice.warning { border: 1px solid #f0d6a4; color: #8c5a12; background: #fff8e9; }
+.delete-warning { margin: 18px 0 0; padding: 12px 14px; border: 1px solid #f1c9c5; border-radius: 12px; color: #a23f3f; background: #fff4f2; font-size: 12px; line-height: 1.6; }
+.push-left { margin-right: auto; }
+.read-only { color: #71817b; font-size: 12px; }
+
+@media (max-width: 1180px) { .summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 700px) {
+  .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+  .summary-grid article { padding: 15px; border-radius: 14px; }
+  .summary-grid article:first-child { grid-column: 1 / -1; }
+  .store-editor { padding: 20px 16px; }
+  .store-editor footer { flex-wrap: wrap; }
+  .store-editor footer .ui-button { flex: 1 1 auto; }
+  .store-editor footer .push-left { flex-basis: 100%; margin-right: 0; }
+}

+ 165 - 0
src/app/admin/pages/operations-stores.component.ts

@@ -0,0 +1,165 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Building2, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, Users, X } from 'lucide-angular';
+import { LucideAngularModule } from 'lucide-angular';
+import { StorePage, StoreSummary } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+@Component({
+  selector: 'app-operations-stores',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './operations-stores.component.html',
+  styleUrls: ['./operations-professional.scss', './operations-stores.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OperationsStoresComponent implements OnInit {
+  readonly page = signal<StorePage | null>(null);
+  readonly query = signal('');
+  readonly status = signal('all');
+  readonly loading = signal(true);
+  readonly saving = signal(false);
+  readonly error = signal('');
+  readonly message = signal('');
+  readonly drawerOpen = signal(false);
+  readonly editing = signal<StoreSummary | null>(null);
+  readonly deleteArmed = signal(false);
+
+  readonly username = signal('');
+  readonly password = signal('');
+  readonly storeName = signal('');
+  readonly contactName = signal('');
+  readonly mobile = signal('');
+  readonly parentStoreId = signal(0);
+  readonly disabled = signal(false);
+  readonly reason = signal('');
+
+  readonly icons = { Building2, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, Users, X };
+  private readonly operations = inject(OperationsService);
+
+  ngOnInit(): void { void this.load(1); }
+
+  async load(page = 1): Promise<void> {
+    this.loading.set(true);
+    this.error.set('');
+    try {
+      this.page.set(await this.operations.stores(page, 20, this.query(), { status: this.status() }));
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '门店数据加载失败');
+    } finally {
+      this.loading.set(false);
+    }
+  }
+
+  reset(): void {
+    this.query.set('');
+    this.status.set('all');
+    void this.load(1);
+  }
+
+  pageCount(): number { return Math.max(1, Math.ceil((this.page()?.total || 0) / (this.page()?.pageSize || 20))); }
+
+  availableParents() {
+    const ownId = this.editing()?.userId || 0;
+    return (this.page()?.parents || []).filter((store) => store.userId !== ownId);
+  }
+
+  openCreate(): void {
+    this.editing.set(null);
+    this.username.set('');
+    this.password.set('');
+    this.storeName.set('');
+    this.contactName.set('');
+    this.mobile.set('');
+    this.parentStoreId.set(0);
+    this.disabled.set(false);
+    this.reason.set('新增门店');
+    this.deleteArmed.set(false);
+    this.error.set('');
+    this.drawerOpen.set(true);
+  }
+
+  openEdit(row: StoreSummary, deleteMode = false): void {
+    this.editing.set(row);
+    this.username.set(row.username);
+    this.password.set('');
+    this.storeName.set(row.storeName);
+    this.contactName.set(row.contactName || '');
+    this.mobile.set(row.mobile || '');
+    this.parentStoreId.set(row.parentStoreId || 0);
+    this.disabled.set(row.status === 'disabled');
+    this.reason.set('');
+    this.deleteArmed.set(deleteMode);
+    this.error.set('');
+    this.drawerOpen.set(true);
+  }
+
+  closeDrawer(): void {
+    this.drawerOpen.set(false);
+    this.deleteArmed.set(false);
+  }
+
+  async save(): Promise<void> {
+    const editing = this.editing();
+    if (!this.username().trim() || !this.storeName().trim() || !this.reason().trim()) {
+      this.error.set('登录账号、门店名称和操作原因必填');
+      return;
+    }
+    if (!editing && !this.password()) {
+      this.error.set('新增门店必须设置初始密码');
+      return;
+    }
+    this.saving.set(true);
+    this.error.set('');
+    try {
+      await this.operations.mutate(
+        editing ? 'ops/stores/update' : 'ops/stores/create',
+        editing?.objectId || '',
+        this.reason(),
+        {
+          username: this.username().trim(),
+          password: this.password(),
+          storeName: this.storeName().trim(),
+          contactName: this.contactName().trim(),
+          mobile: this.mobile().trim(),
+          parentStoreId: this.parentStoreId(),
+          disabled: this.disabled(),
+        },
+        editing?.updatedAt || '',
+      );
+      this.message.set(editing ? '门店资料已更新' : '门店已新增');
+      this.closeDrawer();
+      await this.load(editing ? this.page()?.page || 1 : 1);
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '门店资料保存失败');
+    } finally {
+      this.saving.set(false);
+    }
+  }
+
+  async remove(): Promise<void> {
+    const editing = this.editing();
+    if (!editing) return;
+    if (!this.reason().trim()) {
+      this.error.set('删除前必须填写操作原因');
+      return;
+    }
+    if (!this.deleteArmed()) {
+      this.deleteArmed.set(true);
+      return;
+    }
+    this.saving.set(true);
+    this.error.set('');
+    try {
+      await this.operations.mutate('ops/stores/delete', editing.objectId, this.reason(), {}, editing.updatedAt);
+      this.message.set('门店已删除,历史业务数据仍保留');
+      this.closeDrawer();
+      await this.load(1);
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '门店删除失败');
+    } finally {
+      this.saving.set(false);
+    }
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов