Ver código fonte

feat: 手机号登录、组织数据范围与小区管理

- 认证改为手机号登录,注册必填邮箱,找回密码通过邮箱重置
- 移除单群角色,角色由后台分配
- 新增小区/组织架构 API、数据范围鉴权与看板门店筛选
- 补充生命周期字段、Python 接口测试与风控工单开发说明

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 3 meses atrás
pai
commit
5a4f62d750
48 arquivos alterados com 3022 adições e 514 exclusões
  1. 3 0
      .gitignore
  2. 12 25
      backend/backend/src/apps/pc/auth/controllers/auth.controller.ts
  3. 107 38
      backend/backend/src/apps/pc/auth/services/auth.service.ts
  4. 5 3
      backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts
  5. 57 55
      backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts
  6. 35 0
      backend/backend/src/apps/pc/qiwe/controllers/community.controller.ts
  7. 42 4
      backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts
  8. 28 0
      backend/backend/src/apps/pc/qiwe/controllers/org.controller.ts
  9. 12 0
      backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts
  10. 150 0
      backend/backend/src/apps/pc/qiwe/services/community.service.ts
  11. 58 16
      backend/backend/src/apps/pc/qiwe/services/groups.service.ts
  12. 235 0
      backend/backend/src/apps/pc/qiwe/services/org-sync.service.ts
  13. 8 1
      backend/backend/src/apps/pc/qiwe/services/organization.service.ts
  14. 43 0
      backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts
  15. 116 7
      backend/backend/src/apps/pc/qiwe/utils/group-metrics.util.ts
  16. 56 0
      backend/backend/src/apps/pc/qiwe/utils/lifecycle.util.ts
  17. 1 1
      backend/backend/src/index.ts
  18. 173 0
      backend/backend/src/shared/auth/data-scope.service.ts
  19. 16 0
      backend/backend/src/shared/auth/require-user.ts
  20. 4 0
      backend/backend/src/shared/db/organization-bootstrap.ts
  21. 30 0
      backend/backend/src/shared/db/schema-setup.ts
  22. 198 0
      backend/backend/tests_python/run_auto_test.mjs
  23. 118 0
      backend/backend/tests_python/run_p0_scope_test.ps1
  24. 622 0
      doc/风控工单-后端开发说明.md
  25. 4 0
      src/app/core/auth/auth-api.types.ts
  26. 22 25
      src/app/core/auth/auth.service.ts
  27. 16 0
      src/app/core/models/index.ts
  28. 30 0
      src/app/core/models/lifecycle.constants.ts
  29. 8 6
      src/app/core/models/role.constants.ts
  30. 16 16
      src/app/core/services/api/dashboard-api.service.ts
  31. 58 1
      src/app/core/services/api/qiwe-api.service.ts
  32. 63 18
      src/app/core/services/mock-data.service.ts
  33. 62 12
      src/app/features/auth/forgot-password/forgot-password.component.html
  34. 49 5
      src/app/features/auth/forgot-password/forgot-password.component.ts
  35. 12 47
      src/app/features/auth/login/login.component.html
  36. 23 19
      src/app/features/auth/login/login.component.ts
  37. 24 60
      src/app/features/auth/register/register.component.html
  38. 11 17
      src/app/features/auth/register/register.component.ts
  39. 67 52
      src/app/features/dashboard/dashboard-scope.util.ts
  40. 19 6
      src/app/features/dashboard/dashboard.component.html
  41. 109 35
      src/app/features/dashboard/dashboard.component.ts
  42. 35 11
      src/app/features/group-management/community-list/community-list.component.html
  43. 66 6
      src/app/features/group-management/community-list/community-list.component.ts
  44. 21 4
      src/app/features/group-management/group-list/group-list.component.ts
  45. 2 24
      src/app/features/workspace/workspace.component.ts
  46. 8 0
      src/app/shared/components/status-badge/status-badge.component.ts
  47. 60 0
      src/app/shared/components/store-scope-picker/store-scope-picker.component.html
  48. 108 0
      src/app/shared/components/store-scope-picker/store-scope-picker.component.ts

+ 3 - 0
.gitignore

@@ -52,3 +52,6 @@ _tmp_backend_zip/
 backend/backend/node_modules/
 lami-base-v1/.venv/
 lami-base-v1/**/node_modules/
+
+# Local docs (not for version control)
+backend/backend/docs/P0-功能改造说明.md

+ 12 - 25
backend/backend/src/apps/pc/auth/controllers/auth.controller.ts

@@ -1,40 +1,28 @@
 import type { Request, Response } from 'express';
 import { AppError } from '../../../../shared/errors/app-error.js';
 import { sendSuccess } from '../../../../shared/http/response.js';
+import { requireUser } from '../../../../shared/auth/require-user.js';
 import * as authService from '../services/auth.service.js';
 import { getBearerToken } from '../utils/request.util.js';
 
-async function requireUser(req: Request): Promise<authService.UserDto> {
-  const token = getBearerToken(req);
-  if (!token) {
-    throw new AppError(401, 'UNAUTHORIZED', '未登录');
-  }
-  const user = await authService.getUserByToken(token);
-  if (!user) {
-    throw new AppError(401, 'SESSION_EXPIRED', '登录已过期,请重新登录');
-  }
-  return user;
-}
-
 export async function handleLogin(req: Request, res: Response): Promise<void> {
-  const { email, password, role, rememberMe } = req.body ?? {};
-  if (!email || !password || !role) {
-    throw new AppError(400, 'INVALID_BODY', '请提供 email、password、role');
+  const { phone, password, rememberMe } = req.body ?? {};
+  if (!phone || !password) {
+    throw new AppError(400, 'INVALID_BODY', '请提供 phone、password');
   }
 
   const result = await authService.loginWithRemember(
-    String(email),
+    String(phone),
     String(password),
-    String(role),
     Boolean(rememberMe),
   );
   sendSuccess(res, result);
 }
 
 export async function handleRegister(req: Request, res: Response): Promise<void> {
-  const { name, email, phone, password, role } = req.body ?? {};
+  const { name, email, phone, password } = req.body ?? {};
   if (!name || !email || !phone || !password) {
-    throw new AppError(400, 'INVALID_BODY', '请填写完整注册信息');
+    throw new AppError(400, 'INVALID_BODY', '请填写姓名、邮箱、手机号和密码');
   }
 
   const user = await authService.register({
@@ -42,19 +30,18 @@ export async function handleRegister(req: Request, res: Response): Promise<void>
     email: String(email),
     phone: String(phone),
     password: String(password),
-      role: (role as authService.UserRole) || 'single_group',
   });
   sendSuccess(res, { user }, 201);
 }
 
 export async function handleForgotPassword(req: Request, res: Response): Promise<void> {
-  const { email } = req.body ?? {};
-  if (!email) {
-    throw new AppError(400, 'INVALID_BODY', '请提供邮箱');
+  const { email, newPassword } = req.body ?? {};
+  if (!email || !newPassword) {
+    throw new AppError(400, 'INVALID_BODY', '请提供邮箱和新密码');
   }
 
-  await authService.forgotPassword(String(email));
-  sendSuccess(res, { message: '若该邮箱已注册,请联系管理员重置密码' });
+  await authService.resetPasswordByEmail(String(email), String(newPassword));
+  sendSuccess(res, { message: '密码已重置,请使用新密码登录' });
 }
 
 export async function handleMe(req: Request, res: Response): Promise<void> {

+ 107 - 38
backend/backend/src/apps/pc/auth/services/auth.service.ts

@@ -2,8 +2,9 @@ import { randomBytes } from 'crypto';
 import Parse from '../../../../shared/db/parse-client.js';
 import { AppError } from '../../../../shared/errors/app-error.js';
 import { hashPassword, verifyPassword } from '../utils/password.util.js';
+import { getDefaultStore } from '../../qiwe/services/organization.service.js';
 
-export type UserRole = 'director' | 'regional_supervisor' | 'store_manager' | 'single_group';
+export type UserRole = 'director' | 'regional_supervisor' | 'store_manager';
 
 export interface UserDto {
   id: string;
@@ -13,18 +14,24 @@ export interface UserDto {
   role: UserRole;
   storeId: string;
   storeName: string;
+  /** 企微 userId,与 GroupChat.ownerId 关联 */
+  wecomUserId: string;
+  /** 区域督导所辖区域,与 Store.region 对齐 */
+  regionCode: string;
   department: string;
   position: string;
   createdAt: string;
 }
 
-const VALID_ROLES: UserRole[] = ['director', 'regional_supervisor', 'store_manager', 'single_group'];
+const VALID_ROLES: UserRole[] = ['director', 'regional_supervisor', 'store_manager'];
+const DEFAULT_REGISTER_ROLE: UserRole = 'store_manager';
 
 const LEGACY_ROLE_MAP: Record<string, UserRole> = {
   admin: 'director',
-  operator: 'single_group',
-  designer: 'single_group',
-  sales: 'single_group',
+  operator: 'store_manager',
+  designer: 'store_manager',
+  sales: 'store_manager',
+  single_group: 'store_manager',
   store_manager: 'store_manager',
 };
 const SESSION_DAYS_REMEMBER = 30;
@@ -37,8 +44,10 @@ function toUserDto(obj: Parse.Object): UserDto {
     email: obj.get('email') || '',
     phone: obj.get('phone') || '',
     role: obj.get('role') as UserRole,
-    storeId: obj.get('storeId') || 's1',
+    storeId: obj.get('storeId') || '',
     storeName: obj.get('storeName') || '',
+    wecomUserId: obj.get('wecomUserId') || '',
+    regionCode: obj.get('regionCode') || '',
     department: obj.get('department') || '',
     position: obj.get('position') || '',
     createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
@@ -69,6 +78,24 @@ async function ensureUserRole(user: Parse.Object): Promise<UserRole> {
   return normalized;
 }
 
+function normalizePhone(phone: string): string {
+  return phone.trim();
+}
+
+function isValidPhone(phone: string): boolean {
+  return /^\d{11}$/.test(normalizePhone(phone));
+}
+
+function isValidEmail(email: string): boolean {
+  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim().toLowerCase());
+}
+
+async function findUserByPhone(phone: string): Promise<Parse.Object | undefined> {
+  const query = new Parse.Query('AppUser');
+  query.equalTo('phone', normalizePhone(phone));
+  return (await query.first({ useMasterKey: true })) ?? undefined;
+}
+
 async function findUserByEmail(email: string): Promise<Parse.Object | undefined> {
   const query = new Parse.Query('AppUser');
   query.equalTo('email', email.trim().toLowerCase());
@@ -89,27 +116,26 @@ async function createSession(userId: string, rememberMe: boolean): Promise<strin
 }
 
 export async function loginWithRemember(
-  email: string,
+  phone: string,
   password: string,
-  role: string,
   rememberMe: boolean,
 ): Promise<{ user: UserDto; token: string }> {
-  const normalizedEmail = email.trim().toLowerCase();
-  const userObj = await findUserByEmail(normalizedEmail);
+  if (!isValidPhone(phone)) {
+    throw new AppError(400, 'INVALID_PHONE', '手机号格式不正确');
+  }
+
+  const userObj = await findUserByPhone(phone);
   if (!userObj) {
-    throw new AppError(401, 'AUTH_FAILED', '邮箱或密码错误');
+    throw new AppError(401, 'AUTH_FAILED', '手机号或密码错误');
   }
 
   const hash = userObj.get('passwordHash') as string;
   const salt = userObj.get('passwordSalt') as string;
   if (!verifyPassword(password, hash, salt)) {
-    throw new AppError(401, 'AUTH_FAILED', '邮箱或密码错误');
+    throw new AppError(401, 'AUTH_FAILED', '手机号或密码错误');
   }
 
-  const storedRole = await ensureUserRole(userObj);
-  if (storedRole !== role) {
-    throw new AppError(401, 'ROLE_MISMATCH', '角色不匹配,请选择正确的登录角色');
-  }
+  await ensureUserRole(userObj);
 
   const token = await createSession(userObj.id!, rememberMe);
   return { user: toUserDto(userObj), token };
@@ -129,40 +155,75 @@ export async function register(data: {
   email: string;
   phone: string;
   password: string;
-  role: UserRole;
 }): Promise<UserDto> {
+  if (!isValidPhone(data.phone)) {
+    throw new AppError(400, 'INVALID_PHONE', '手机号必须为11位数字');
+  }
+
   const email = data.email.trim().toLowerCase();
-  if (!VALID_ROLES.includes(data.role)) {
-    throw new AppError(400, 'INVALID_ROLE', '无效的角色');
+  if (!email) {
+    throw new AppError(400, 'INVALID_EMAIL', '请输入邮箱');
+  }
+  if (!isValidEmail(email)) {
+    throw new AppError(400, 'INVALID_EMAIL', '邮箱格式不正确');
   }
 
-  const existing = await findUserByEmail(email);
-  if (existing) {
+  const phone = normalizePhone(data.phone);
+  const existingPhone = await findUserByPhone(phone);
+  if (existingPhone) {
+    throw new AppError(400, 'PHONE_EXISTS', '该手机号已被注册');
+  }
+
+  const existingEmail = await findUserByEmail(email);
+  if (existingEmail) {
     throw new AppError(400, 'EMAIL_EXISTS', '该邮箱已被注册');
   }
 
+  const defaultStore = await getDefaultStore();
   const { hash, salt } = hashPassword(data.password);
   const obj = new Parse.Object('AppUser');
   obj.set('email', email);
   obj.set('passwordHash', hash);
   obj.set('passwordSalt', salt);
   obj.set('name', data.name.trim());
-  obj.set('phone', data.phone.trim());
-  obj.set('role', data.role);
-  obj.set('storeId', 's1');
-  obj.set('storeName', '上海总部');
+  obj.set('phone', phone);
+  obj.set('role', DEFAULT_REGISTER_ROLE);
+  obj.set('storeId', defaultStore.storeId);
+  obj.set('storeName', defaultStore.storeName);
   obj.set('department', '运营部');
   obj.set('position', '新注册用户');
   await obj.save(null, { useMasterKey: true });
   return toUserDto(obj);
 }
 
-export async function forgotPassword(email: string): Promise<void> {
-  const user = await findUserByEmail(email.trim().toLowerCase());
+export async function resetPasswordByEmail(email: string, newPassword: string): Promise<void> {
+  const normalizedEmail = email.trim().toLowerCase();
+  if (!normalizedEmail) {
+    throw new AppError(400, 'INVALID_EMAIL', '请输入邮箱');
+  }
+  if (!isValidEmail(normalizedEmail)) {
+    throw new AppError(400, 'INVALID_EMAIL', '邮箱格式不正确');
+  }
+  if (!newPassword || newPassword.length < 6) {
+    throw new AppError(400, 'INVALID_PASSWORD', '新密码长度不能少于6位');
+  }
+
+  const user = await findUserByEmail(normalizedEmail);
   if (!user) {
     throw new AppError(404, 'EMAIL_NOT_FOUND', '该邮箱未注册');
   }
-  // 暂未接入邮件服务,仅校验邮箱存在
+
+  const next = hashPassword(newPassword);
+  user.set('passwordHash', next.hash);
+  user.set('passwordSalt', next.salt);
+  await user.save(null, { useMasterKey: true });
+
+  const sessionQuery = new Parse.Query('AuthSession');
+  sessionQuery.equalTo('userId', user.id!);
+  const sessions = await sessionQuery.find({ useMasterKey: true });
+  if (sessions.length > 0) {
+    await Parse.Object.destroyAll(sessions, { useMasterKey: true });
+  }
 }
 
 export async function getUserByToken(token: string): Promise<UserDto | null> {
@@ -217,15 +278,14 @@ const SEED_USERS: Array<{
   name: string;
   phone: string;
   role: UserRole;
-  storeId: string;
-  storeName: string;
+  wecomUserId: string;
+  regionCode: string;
   department: string;
   position: string;
 }> = [
-  { email: 'admin@lami.com', password: '123456', name: '张总监', phone: '13800000001', role: 'director', storeId: 's1', storeName: '上海总部', department: '总部', position: '总监' },
-  { email: 'dudao@lami.com', password: '123456', name: '周督导', phone: '13800000002', role: 'regional_supervisor', storeId: 's1', storeName: '上海总部', department: '督导部', position: '区域督导' },
-  { email: 'lidian@lami.com', password: '123456', name: '李店长', phone: '13800000003', role: 'store_manager', storeId: 's1', storeName: '上海总部', department: '直营店', position: '店长' },
-  { email: 'wangying@lami.com', password: '123456', name: '王单群', phone: '13800000004', role: 'single_group', storeId: 's1', storeName: '上海总部', department: '运营部', position: '单群' },
+  { email: 'admin@lami.com', password: '123456', name: '张总监', phone: '13800000001', role: 'director', wecomUserId: '', regionCode: '', department: '总部', position: '总监' },
+  { email: 'dudao@lami.com', password: '123456', name: '周督导', phone: '13800000002', role: 'regional_supervisor', wecomUserId: '', regionCode: '华东', department: '督导部', position: '区域督导' },
+  { email: 'lidian@lami.com', password: '123456', name: '李店长', phone: '13800000003', role: 'store_manager', wecomUserId: '', regionCode: '', department: '直营店', position: '店长' },
 ];
 
 async function migrateLegacyRoles(): Promise<void> {
@@ -252,6 +312,7 @@ async function migrateLegacyRoles(): Promise<void> {
 async function upsertSeedUsers(): Promise<void> {
   let created = 0;
   let updated = 0;
+  const defaultStore = await getDefaultStore();
 
   for (const seed of SEED_USERS) {
     const existing = await findUserByEmail(seed.email);
@@ -263,8 +324,12 @@ async function upsertSeedUsers(): Promise<void> {
       existing.set('name', seed.name);
       existing.set('phone', seed.phone);
       existing.set('role', seed.role);
-      existing.set('storeId', seed.storeId);
-      existing.set('storeName', seed.storeName);
+      if (seed.role !== 'director') {
+        existing.set('storeId', defaultStore.storeId);
+        existing.set('storeName', defaultStore.storeName);
+      }
+      existing.set('wecomUserId', seed.wecomUserId);
+      existing.set('regionCode', seed.regionCode);
       existing.set('department', seed.department);
       existing.set('position', seed.position);
       await existing.save(null, { useMasterKey: true });
@@ -279,8 +344,12 @@ async function upsertSeedUsers(): Promise<void> {
     obj.set('name', seed.name);
     obj.set('phone', seed.phone);
     obj.set('role', seed.role);
-    obj.set('storeId', seed.storeId);
-    obj.set('storeName', seed.storeName);
+    if (seed.role !== 'director') {
+      obj.set('storeId', defaultStore.storeId);
+      obj.set('storeName', defaultStore.storeName);
+    }
+    obj.set('wecomUserId', seed.wecomUserId);
+    obj.set('regionCode', seed.regionCode);
     obj.set('department', seed.department);
     obj.set('position', seed.position);
     await obj.save(null, { useMasterKey: true });

+ 5 - 3
backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts

@@ -1,11 +1,13 @@
 import type { Request, Response } from 'express';
 import { sendSuccess } from '../../../../shared/http/response.js';
+import { requireUser } from '../../../../shared/auth/require-user.js';
 import { getDashboardOverview } from '../services/dashboard.service.js';
 
 export async function handleDashboardOverview(req: Request, res: Response): Promise<void> {
-  const role = typeof req.query.role === 'string' ? req.query.role : undefined;
-  const entityId = typeof req.query.entityId === 'string' ? req.query.entityId : undefined;
+  const user = await requireUser(req);
+  const scopeLevel = typeof req.query.scopeLevel === 'string' ? req.query.scopeLevel : undefined;
+  const storeId = typeof req.query.storeId === 'string' ? req.query.storeId : undefined;
 
-  const overview = await getDashboardOverview({ role, entityId });
+  const overview = await getDashboardOverview(user, { scopeLevel, storeId });
   sendSuccess(res, overview);
 }

+ 57 - 55
backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts

@@ -1,11 +1,12 @@
-import Parse from '../../../../shared/db/parse-client.js';
 import type { UserDto } from '../../auth/services/auth.service.js';
+import {
+  filterGroupsByDataScope,
+  resolveDataScope,
+  type DataScopeRequest,
+  type ResolvedDataScope,
+} from '../../../../shared/auth/data-scope.service.js';
 import { listGroupChats, type GroupChatDto } from '../../qiwe/services/groups.service.js';
-
-export interface DashboardScope {
-  role?: string;
-  entityId?: string;
-}
+import { listStores, type StoreDto } from '../../qiwe/services/organization.service.js';
 
 export interface DashboardStatsDto {
   totalGroups: number;
@@ -22,52 +23,14 @@ export interface DashboardStatsDto {
 export interface DashboardOverviewDto {
   stats: DashboardStatsDto;
   activityDistribution: { high: number; medium: number; low: number; inactive: number };
+  lifecycleDistribution: Array<{ phase: string; label: string; count: number }>;
+  healthGradeDistribution: Record<string, number>;
   storeComparison: Array<{ storeName: string; groupCount: number }>;
   topGroups: GroupChatDto[];
   recentRiskEvents: [];
-  scopeUsers: UserDto[];
-}
-
-export async function listScopeUsers(): Promise<UserDto[]> {
-  const query = new Parse.Query('AppUser');
-  query.ascending('role');
-  query.limit(200);
-  const users = await query.find({ useMasterKey: true });
-  return users.map((user) => ({
-    id: user.id!,
-    name: user.get('name') || '',
-    email: user.get('email') || '',
-    phone: user.get('phone') || '',
-    role: user.get('role'),
-    storeId: user.get('storeId') || '',
-    storeName: user.get('storeName') || '',
-    department: user.get('department') || '',
-    position: user.get('position') || '',
-    createdAt: (user.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
-  }));
-}
-
-function filterGroupsByScope(groups: GroupChatDto[], scope: DashboardScope, users: UserDto[]): GroupChatDto[] {
-  const { role, entityId } = scope;
-
-  if (!entityId) {
-    return groups;
-  }
-
-  const entity = users.find((u) => u.id === entityId);
-  if (!entity) return groups;
-
-  switch (role) {
-    case 'director':
-      return groups;
-    case 'regional_supervisor':
-    case 'store_manager':
-      return entity.storeId ? groups.filter((g) => g.storeId === entity.storeId) : [];
-    case 'single_group':
-      return groups.filter((g) => g.ownerId && g.ownerId === entityId);
-    default:
-      return groups;
-  }
+  stores: StoreDto[];
+  scope: ResolvedDataScope;
+  viewer: Pick<UserDto, 'id' | 'name' | 'role' | 'storeId' | 'storeName' | 'regionCode'>;
 }
 
 function computeStats(groups: GroupChatDto[]): DashboardStatsDto {
@@ -124,13 +87,41 @@ function buildStoreComparison(groups: GroupChatDto[]) {
   }));
 }
 
-export async function getDashboardOverview(scope: DashboardScope = {}): Promise<DashboardOverviewDto> {
-  const [allGroups, scopeUsers] = await Promise.all([
+function buildLifecycleDistribution(groups: GroupChatDto[]) {
+  const labels: Record<string, string> = {
+    pre_handover: '交房前',
+    near_handover: '临交房',
+    post_handover: '已交房',
+    legacy: '老小区',
+  };
+  const phases = ['pre_handover', 'near_handover', 'post_handover', 'legacy'];
+  return phases.map((phase) => ({
+    phase,
+    label: labels[phase] || phase,
+    count: groups.filter((g) => g.lifecyclePhase === phase).length,
+  }));
+}
+
+function buildHealthGradeDistribution(groups: GroupChatDto[]) {
+  const grades = ['S', 'A', 'B', 'C'];
+  const result: Record<string, number> = {};
+  for (const grade of grades) {
+    result[grade] = groups.filter((g) => g.healthGrade === grade).length;
+  }
+  return result;
+}
+
+export async function getDashboardOverview(
+  user: UserDto,
+  scopeRequest: DataScopeRequest = {},
+): Promise<DashboardOverviewDto> {
+  const [allGroups, stores, scope] = await Promise.all([
     listGroupChats(),
-    listScopeUsers(),
+    listStores(),
+    resolveDataScope(user, scopeRequest),
   ]);
 
-  const groups = filterGroupsByScope(allGroups, scope, scopeUsers);
+  const groups = filterGroupsByDataScope(allGroups, scope);
   const topGroups = [...groups]
     .sort((a, b) => b.messageCountToday - a.messageCountToday)
     .slice(0, 10);
@@ -138,9 +129,20 @@ export async function getDashboardOverview(scope: DashboardScope = {}): Promise<
   return {
     stats: computeStats(groups),
     activityDistribution: buildActivityDistribution(groups),
-    storeComparison: buildStoreComparison(groups),
+    lifecycleDistribution: buildLifecycleDistribution(groups),
+    healthGradeDistribution: buildHealthGradeDistribution(groups),
+    storeComparison: buildStoreComparison(scope.level === 'global' ? allGroups : groups),
     topGroups,
     recentRiskEvents: [],
-    scopeUsers,
+    stores,
+    scope,
+    viewer: {
+      id: user.id,
+      name: user.name,
+      role: user.role,
+      storeId: user.storeId,
+      storeName: user.storeName,
+      regionCode: user.regionCode,
+    },
   };
 }

+ 35 - 0
backend/backend/src/apps/pc/qiwe/controllers/community.controller.ts

@@ -0,0 +1,35 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { requireUser } from '../../../../shared/auth/require-user.js';
+import { resolveDataScope } from '../../../../shared/auth/data-scope.service.js';
+import {
+  groupCommunitiesByLifecycle,
+  listCommunities,
+} from '../services/community.service.js';
+import type { LifecyclePhase } from '../utils/lifecycle.util.js';
+
+export async function handleListCommunities(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  const scope = await resolveDataScope(user, {
+    scopeLevel: typeof req.query.scopeLevel === 'string' ? req.query.scopeLevel : undefined,
+    storeId: typeof req.query.storeId === 'string' ? req.query.storeId : undefined,
+  });
+
+  const lifecyclePhase = typeof req.query.lifecyclePhase === 'string'
+    ? req.query.lifecyclePhase as LifecyclePhase
+    : undefined;
+
+  let storeId: string | undefined;
+  if (scope.level === 'store') storeId = scope.storeId;
+  else if (typeof req.query.storeId === 'string') storeId = req.query.storeId;
+
+  let communities = await listCommunities({ storeId, lifecyclePhase });
+
+  if (scope.level === 'region' && scope.storeIds?.length) {
+    const allowed = new Set(scope.storeIds);
+    communities = communities.filter((c) => allowed.has(c.storeId));
+  }
+
+  const grouped = groupCommunitiesByLifecycle(communities);
+  sendSuccess(res, { communities, grouped, total: communities.length });
+}

+ 42 - 4
backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts

@@ -1,6 +1,11 @@
 import type { Request, Response } from 'express';
 import { AppError } from '../../../../shared/errors/app-error.js';
 import { sendSuccess } from '../../../../shared/http/response.js';
+import { requireUser } from '../../../../shared/auth/require-user.js';
+import {
+  filterGroupsByDataScope,
+  resolveDataScope,
+} from '../../../../shared/auth/data-scope.service.js';
 import { getGroupChatByRoomId, listGroupChats } from '../services/groups.service.js';
 import { listStores } from '../services/organization.service.js';
 
@@ -12,25 +17,58 @@ function parseBoolean(value: unknown): boolean | undefined {
 }
 
 export async function handleListGroups(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  const scopeLevelQ = typeof req.query.scopeLevel === 'string' ? req.query.scopeLevel : undefined;
+  const storeIdForScope = typeof req.query.storeId === 'string' ? req.query.storeId : undefined;
+
+  const scope = await resolveDataScope(user, {
+    scopeLevel: scopeLevelQ,
+    storeId: scopeLevelQ === 'store' ? storeIdForScope : undefined,
+  });
+
+  let filterStoreId: string | undefined;
+  if (scope.level === 'store') {
+    filterStoreId = scope.storeId;
+  } else if (storeIdForScope) {
+    if (user.role !== 'director') {
+      throw new AppError(403, 'SCOPE_DENIED', '无权按该门店筛选');
+    }
+    filterStoreId = storeIdForScope;
+  }
+
   const groups = await listGroupChats({
-    storeId: typeof req.query.storeId === 'string' ? req.query.storeId : undefined,
+    storeId: filterStoreId || undefined,
     activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
     healthStatus: typeof req.query.healthStatus === 'string' ? req.query.healthStatus : undefined,
+    healthGrade: typeof req.query.healthGrade === 'string' ? req.query.healthGrade : undefined,
+    lifecyclePhase: typeof req.query.lifecyclePhase === 'string' ? req.query.lifecyclePhase : undefined,
     hasDocument: parseBoolean(req.query.hasDocument),
     q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
   });
+
+  const scopedGroups = filterGroupsByDataScope(groups, scope);
   const stores = await listStores();
-  sendSuccess(res, { groups, total: groups.length, stores });
+  sendSuccess(res, { groups: scopedGroups, total: scopedGroups.length, stores, scope });
 }
 
 export async function handleGetGroup(req: Request, res: Response): Promise<void> {
-  const roomId = req.params.roomId;
+  const user = await requireUser(req);
+  const rawRoomId = req.params.roomId;
+  const roomId = Array.isArray(rawRoomId) ? rawRoomId[0] : rawRoomId;
   if (!roomId) {
     throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
   }
+
   const group = await getGroupChatByRoomId(roomId);
   if (!group) {
     throw new AppError(404, 'GROUP_NOT_FOUND', '群不存在');
   }
-  sendSuccess(res, { group });
+
+  const scope = await resolveDataScope(user, {});
+  const [scoped] = filterGroupsByDataScope([group], scope);
+  if (!scoped) {
+    throw new AppError(403, 'SCOPE_DENIED', '无权查看该群');
+  }
+
+  sendSuccess(res, { group: scoped });
 }

+ 28 - 0
backend/backend/src/apps/pc/qiwe/controllers/org.controller.ts

@@ -0,0 +1,28 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { requireUser } from '../../../../shared/auth/require-user.js';
+import { buildOrgTree, listOrgDepartments, listOrgMembers, syncOrgFromQiWe } from '../services/org-sync.service.js';
+
+export async function handleGetOrgTree(req: Request, res: Response): Promise<void> {
+  await requireUser(req);
+  const tree = await buildOrgTree();
+  sendSuccess(res, { tree });
+}
+
+export async function handleListOrgDepartments(req: Request, res: Response): Promise<void> {
+  await requireUser(req);
+  const departments = await listOrgDepartments();
+  sendSuccess(res, { departments });
+}
+
+export async function handleListOrgMembers(req: Request, res: Response): Promise<void> {
+  await requireUser(req);
+  const members = await listOrgMembers();
+  sendSuccess(res, { members });
+}
+
+export async function handleSyncOrg(req: Request, res: Response): Promise<void> {
+  await requireUser(req);
+  const result = await syncOrgFromQiWe();
+  sendSuccess(res, result);
+}

+ 12 - 0
backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts

@@ -4,10 +4,22 @@ import { handleWebhook } from '../controllers/webhook.controller.js';
 import { handleSyncGroups } from '../controllers/sync.controller.js';
 import { handleListGroups, handleGetGroup } from '../controllers/groups.controller.js';
 import { handleListStores } from '../controllers/stores.controller.js';
+import { handleListCommunities } from '../controllers/community.controller.js';
+import {
+  handleGetOrgTree,
+  handleListOrgDepartments,
+  handleListOrgMembers,
+  handleSyncOrg,
+} from '../controllers/org.controller.js';
 
 const router = Router();
 
 router.get('/stores', asyncHandler(handleListStores));
+router.get('/communities', asyncHandler(handleListCommunities));
+router.get('/org/tree', asyncHandler(handleGetOrgTree));
+router.get('/org/departments', asyncHandler(handleListOrgDepartments));
+router.get('/org/members', asyncHandler(handleListOrgMembers));
+router.post('/org/sync', asyncHandler(handleSyncOrg));
 router.get('/groups', asyncHandler(handleListGroups));
 router.get('/groups/:roomId', asyncHandler(handleGetGroup));
 router.post('/webhook', asyncHandler(handleWebhook));

+ 150 - 0
backend/backend/src/apps/pc/qiwe/services/community.service.ts

@@ -0,0 +1,150 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import { deriveLifecyclePhase, type LifecyclePhase } from '../utils/lifecycle.util.js';
+import { listStores } from './organization.service.js';
+
+export interface CommunityDto {
+  id: string;
+  name: string;
+  address: string;
+  deliveryDate: string;
+  lifecyclePhase: LifecyclePhase;
+  totalHouseholds: number;
+  avgPrice: number;
+  storeId: string;
+  storeName: string;
+  groupCount: number;
+  status: string;
+}
+
+export interface LifecycleSummaryDto {
+  phase: LifecyclePhase;
+  label: string;
+  count: number;
+  communities: CommunityDto[];
+}
+
+const SEED_COMMUNITIES: Array<{
+  name: string;
+  address: string;
+  deliveryDate: string;
+  totalHouseholds: number;
+  avgPrice: number;
+  storeCode: string;
+}> = [
+  { name: '翠湖天地', address: '上海市黄浦区', deliveryDate: '2027-06', totalHouseholds: 1200, avgPrice: 168000, storeCode: 'SH001' },
+  { name: '万达瑞华公馆', address: '上海市徐汇区', deliveryDate: '2026-08', totalHouseholds: 500, avgPrice: 185000, storeCode: 'SH001' },
+  { name: '汤臣一品', address: '上海市浦东新区', deliveryDate: '2025-12', totalHouseholds: 800, avgPrice: 250000, storeCode: 'SH001' },
+  { name: '华侨城苏河湾', address: '上海市静安区', deliveryDate: '2024-03', totalHouseholds: 600, avgPrice: 135000, storeCode: 'SH001' },
+  { name: '北京壹号院', address: '北京市朝阳区', deliveryDate: '2026-09', totalHouseholds: 900, avgPrice: 180000, storeCode: 'BJ001' },
+  { name: '万柳书院', address: '北京市海淀区', deliveryDate: '2022-01', totalHouseholds: 650, avgPrice: 155000, storeCode: 'BJ001' },
+  { name: '深圳湾一号', address: '深圳市南山区', deliveryDate: '2025-06', totalHouseholds: 1000, avgPrice: 200000, storeCode: 'SZ001' },
+  { name: '保利天珺', address: '深圳市福田区', deliveryDate: '2020-03', totalHouseholds: 600, avgPrice: 160000, storeCode: 'SZ001' },
+];
+
+function toDto(obj: Parse.Object): CommunityDto {
+  const deliveryDate = obj.get('deliveryDate') || '';
+  const lifecyclePhase = (obj.get('lifecyclePhase') as LifecyclePhase)
+    || deriveLifecyclePhase(deliveryDate);
+
+  return {
+    id: obj.id!,
+    name: obj.get('name') || '',
+    address: obj.get('address') || '',
+    deliveryDate,
+    lifecyclePhase,
+    totalHouseholds: obj.get('totalHouseholds') ?? 0,
+    avgPrice: obj.get('avgPrice') ?? 0,
+    storeId: obj.get('storeId') || '',
+    storeName: obj.get('storeName') || '',
+    groupCount: obj.get('groupCount') ?? 0,
+    status: obj.get('status') || 'active',
+  };
+}
+
+export async function listCommunities(filters: {
+  storeId?: string;
+  lifecyclePhase?: LifecyclePhase;
+} = {}): Promise<CommunityDto[]> {
+  const query = new Parse.Query('Community');
+  query.equalTo('status', 'active');
+  if (filters.storeId) query.equalTo('storeId', filters.storeId);
+  if (filters.lifecyclePhase) query.equalTo('lifecyclePhase', filters.lifecyclePhase);
+  query.ascending('name');
+  query.limit(500);
+
+  const results = await query.find({ useMasterKey: true });
+  return results.map(toDto);
+}
+
+export function groupCommunitiesByLifecycle(communities: CommunityDto[]): LifecycleSummaryDto[] {
+  const phases: LifecyclePhase[] = ['pre_handover', 'near_handover', 'post_handover', 'legacy'];
+  const labels: Record<LifecyclePhase, string> = {
+    pre_handover: '交房前',
+    near_handover: '临交房',
+    post_handover: '已交房',
+    legacy: '老小区',
+  };
+
+  return phases.map((phase) => {
+    const items = communities.filter((c) => c.lifecyclePhase === phase);
+    return { phase, label: labels[phase], count: items.length, communities: items };
+  });
+}
+
+export async function getCommunityLifecycleMap(): Promise<Map<string, LifecyclePhase>> {
+  const communities = await listCommunities();
+  const map = new Map<string, LifecyclePhase>();
+  for (const c of communities) {
+    map.set(c.id, c.lifecyclePhase);
+  }
+  return map;
+}
+
+export async function bootstrapCommunities(): Promise<void> {
+  const stores = await listStores();
+  const storeByCode = new Map(stores.map((s) => [s.code, s]));
+  let created = 0;
+  let updated = 0;
+
+  for (const seed of SEED_COMMUNITIES) {
+    const store = storeByCode.get(seed.storeCode);
+    if (!store) continue;
+
+    const lifecyclePhase = deriveLifecyclePhase(seed.deliveryDate);
+    const query = new Parse.Query('Community');
+    query.equalTo('name', seed.name);
+    const existing = await query.first({ useMasterKey: true });
+
+    if (existing) {
+      existing.set('address', seed.address);
+      existing.set('deliveryDate', seed.deliveryDate);
+      existing.set('lifecyclePhase', lifecyclePhase);
+      existing.set('totalHouseholds', seed.totalHouseholds);
+      existing.set('avgPrice', seed.avgPrice);
+      existing.set('storeId', store.id);
+      existing.set('storeName', store.name);
+      existing.set('status', 'active');
+      await existing.save(null, { useMasterKey: true });
+      updated++;
+      continue;
+    }
+
+    const obj = new Parse.Object('Community');
+    obj.set('name', seed.name);
+    obj.set('address', seed.address);
+    obj.set('deliveryDate', seed.deliveryDate);
+    obj.set('lifecyclePhase', lifecyclePhase);
+    obj.set('totalHouseholds', seed.totalHouseholds);
+    obj.set('avgPrice', seed.avgPrice);
+    obj.set('storeId', store.id);
+    obj.set('storeName', store.name);
+    obj.set('groupCount', 0);
+    obj.set('status', 'active');
+    await obj.save(null, { useMasterKey: true });
+    created++;
+  }
+
+  if (created > 0 || updated > 0) {
+    console.log(`[Community] 小区种子已同步:新增 ${created},更新 ${updated}`);
+  }
+}

+ 58 - 16
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -1,9 +1,7 @@
 import Parse from '../../../../shared/db/parse-client.js';
-import {
-  deriveActivityLevel,
-  deriveHealthScore,
-  deriveHealthStatus,
-} from '../utils/group-metrics.util.js';
+import { deriveHealthMetrics } from '../utils/group-metrics.util.js';
+import { type LifecyclePhase } from '../utils/lifecycle.util.js';
+import { getCommunityLifecycleMap } from './community.service.js';
 import { resolveOwnerName } from './organization.service.js';
 
 export interface GroupChatDto {
@@ -23,6 +21,10 @@ export interface GroupChatDto {
   activityLevel: string;
   healthScore: number;
   healthStatus: string;
+  healthGrade: string;
+  opsActivityScore: number;
+  customerActivityScore: number;
+  lifecyclePhase: string;
   hasDocument: boolean;
   documentPinned: boolean;
   documentInNotice: boolean;
@@ -35,6 +37,8 @@ export interface GroupListFilters {
   storeId?: string;
   activityLevel?: string;
   healthStatus?: string;
+  healthGrade?: string;
+  lifecyclePhase?: string;
   hasDocument?: boolean;
   q?: string;
 }
@@ -51,16 +55,34 @@ async function countMessagesToday(roomId: string): Promise<number> {
   return query.count({ useMasterKey: true });
 }
 
-async function enrichGroup(obj: Parse.Object, persist = true): Promise<GroupChatDto> {
+async function enrichGroup(
+  obj: Parse.Object,
+  lifecycleMap: Map<string, LifecyclePhase>,
+  persist = true,
+): Promise<GroupChatDto> {
   const roomId = obj.get('roomId') as string;
   const memberCount = obj.get('memberCount') ?? 0;
   const status = obj.get('status') || 'active';
   const hasDocument = obj.get('hasDocument') === true;
+  const documentPinned = obj.get('documentPinned') === true;
+  const documentInNotice = obj.get('documentInNotice') === true;
+  const memberChange24h = obj.get('memberChange24h') ?? 0;
   const messageCountToday = await countMessagesToday(roomId);
 
-  const activityLevel = deriveActivityLevel(messageCountToday, memberCount);
-  const healthScore = deriveHealthScore({ memberCount, messageCountToday, hasDocument, status });
-  const healthStatus = deriveHealthStatus(healthScore, status);
+  const metrics = deriveHealthMetrics({
+    memberCount,
+    messageCountToday,
+    memberChange24h,
+    hasDocument,
+    documentPinned,
+    documentInNotice,
+    status,
+  });
+
+  const communityId = obj.get('communityId') as string;
+  const lifecyclePhase = communityId && lifecycleMap.has(communityId)
+    ? lifecycleMap.get(communityId)!
+    : (obj.get('lifecyclePhase') as string) || 'pre_handover';
 
   let ownerName = (obj.get('ownerName') as string) || '';
   if (!ownerName && obj.get('ownerId')) {
@@ -71,9 +93,13 @@ async function enrichGroup(obj: Parse.Object, persist = true): Promise<GroupChat
   }
 
   obj.set('messageCountToday', messageCountToday);
-  obj.set('activityLevel', activityLevel);
-  obj.set('healthScore', healthScore);
-  obj.set('healthStatus', healthStatus);
+  obj.set('activityLevel', metrics.activityLevel);
+  obj.set('opsActivityScore', metrics.opsActivityScore);
+  obj.set('customerActivityScore', metrics.customerActivityScore);
+  obj.set('healthGrade', metrics.healthGrade);
+  obj.set('healthScore', metrics.healthScore);
+  obj.set('healthStatus', metrics.healthStatus);
+  obj.set('lifecyclePhase', lifecyclePhase);
 
   if (persist) {
     await obj.save(null, { useMasterKey: true });
@@ -100,6 +126,10 @@ function toDto(obj: Parse.Object): GroupChatDto {
     activityLevel: obj.get('activityLevel') || 'inactive',
     healthScore: obj.get('healthScore') ?? 0,
     healthStatus: obj.get('healthStatus') || 'warning',
+    healthGrade: obj.get('healthGrade') || 'C',
+    opsActivityScore: obj.get('opsActivityScore') ?? 0,
+    customerActivityScore: obj.get('customerActivityScore') ?? 0,
+    lifecyclePhase: obj.get('lifecyclePhase') || 'pre_handover',
     hasDocument: obj.get('hasDocument') === true,
     documentPinned: obj.get('documentPinned') === true,
     documentInNotice: obj.get('documentInNotice') === true,
@@ -119,22 +149,33 @@ export async function listGroupChats(
   if (filters.storeId) query.equalTo('storeId', filters.storeId);
   if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel);
   if (filters.healthStatus) query.equalTo('healthStatus', filters.healthStatus);
+  if (filters.healthGrade) query.equalTo('healthGrade', filters.healthGrade);
+  if (filters.lifecyclePhase) query.equalTo('lifecyclePhase', filters.lifecyclePhase);
   if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument);
 
   query.limit(limit);
   const results = await query.find({ useMasterKey: true });
+  const lifecycleMap = await getCommunityLifecycleMap();
 
   const enriched: GroupChatDto[] = [];
   for (const obj of results) {
-    enriched.push(await enrichGroup(obj));
+    enriched.push(await enrichGroup(obj, lifecycleMap));
+  }
+
+  let filtered = enriched;
+  if (filters.lifecyclePhase) {
+    filtered = filtered.filter((g) => g.lifecyclePhase === filters.lifecyclePhase);
+  }
+  if (filters.healthGrade) {
+    filtered = filtered.filter((g) => g.healthGrade === filters.healthGrade);
   }
 
   if (!filters.q) {
-    return enriched;
+    return filtered;
   }
 
   const q = filters.q.toLowerCase();
-  return enriched.filter((g) =>
+  return filtered.filter((g) =>
     g.roomName.toLowerCase().includes(q)
     || g.ownerName.toLowerCase().includes(q)
     || g.communityName.toLowerCase().includes(q)
@@ -147,7 +188,8 @@ export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto
   query.equalTo('roomId', roomId);
   const obj = await query.first({ useMasterKey: true });
   if (!obj) return null;
-  return enrichGroup(obj);
+  const lifecycleMap = await getCommunityLifecycleMap();
+  return enrichGroup(obj, lifecycleMap);
 }
 
 export async function migrateGroupBusinessFields(): Promise<void> {

+ 235 - 0
backend/backend/src/apps/pc/qiwe/services/org-sync.service.ts

@@ -0,0 +1,235 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import {
+  getInternalContactList,
+  getContactDetailsBatch,
+} from './qiwe-api.service.js';
+import { listStores } from './organization.service.js';
+
+export interface OrgDepartmentDto {
+  id: string;
+  partyId: string;
+  name: string;
+  parentPartyId: string;
+  regionCode: string;
+  storeId: string;
+  orderIndex: number;
+}
+
+export interface OrgMemberDto {
+  id: string;
+  userId: string;
+  name: string;
+  partyIds: string[];
+  mobile: string;
+  position: string;
+}
+
+export interface OrgTreeNode {
+  partyId: string;
+  name: string;
+  regionCode: string;
+  storeId: string;
+  children: OrgTreeNode[];
+  members: OrgMemberDto[];
+}
+
+const SEED_DEPARTMENTS = [
+  { partyId: 'root', name: '拉迷总部', parentPartyId: '', regionCode: '', storeId: '', orderIndex: 0 },
+  { partyId: 'region_east', name: '华东区域', parentPartyId: 'root', regionCode: '华东', storeId: '', orderIndex: 1 },
+  { partyId: 'region_north', name: '华北区域', parentPartyId: 'root', regionCode: '华北', storeId: '', orderIndex: 2 },
+  { partyId: 'region_south', name: '华南区域', parentPartyId: 'root', regionCode: '华南', storeId: '', orderIndex: 3 },
+];
+
+function deptToDto(obj: Parse.Object): OrgDepartmentDto {
+  return {
+    id: obj.id!,
+    partyId: obj.get('partyId') || '',
+    name: obj.get('name') || '',
+    parentPartyId: obj.get('parentPartyId') || '',
+    regionCode: obj.get('regionCode') || '',
+    storeId: obj.get('storeId') || '',
+    orderIndex: obj.get('orderIndex') ?? 0,
+  };
+}
+
+function memberToDto(obj: Parse.Object): OrgMemberDto {
+  return {
+    id: obj.id!,
+    userId: obj.get('userId') || '',
+    name: obj.get('name') || '',
+    partyIds: (obj.get('partyIds') as string[]) || [],
+    mobile: obj.get('mobile') || '',
+    position: obj.get('position') || '',
+  };
+}
+
+export async function listOrgDepartments(): Promise<OrgDepartmentDto[]> {
+  const query = new Parse.Query('OrgDepartment');
+  query.ascending('orderIndex');
+  query.limit(500);
+  const results = await query.find({ useMasterKey: true });
+  return results.map(deptToDto);
+}
+
+export async function listOrgMembers(): Promise<OrgMemberDto[]> {
+  const query = new Parse.Query('OrgMember');
+  query.limit(1000);
+  const results = await query.find({ useMasterKey: true });
+  return results.map(memberToDto);
+}
+
+export async function getOrgMemberName(userId: string): Promise<string> {
+  if (!userId) return '';
+  const query = new Parse.Query('OrgMember');
+  query.equalTo('userId', userId);
+  const member = await query.first({ useMasterKey: true });
+  return member?.get('name') || '';
+}
+
+export async function buildOrgTree(): Promise<OrgTreeNode[]> {
+  const [departments, members] = await Promise.all([
+    listOrgDepartments(),
+    listOrgMembers(),
+  ]);
+
+  const nodeMap = new Map<string, OrgTreeNode>();
+  for (const dept of departments) {
+    nodeMap.set(dept.partyId, {
+      partyId: dept.partyId,
+      name: dept.name,
+      regionCode: dept.regionCode,
+      storeId: dept.storeId,
+      children: [],
+      members: [],
+    });
+  }
+
+  for (const member of members) {
+    const primaryParty = member.partyIds[0];
+    const node = primaryParty ? nodeMap.get(primaryParty) : undefined;
+    if (node) node.members.push(member);
+  }
+
+  const roots: OrgTreeNode[] = [];
+  for (const dept of departments) {
+    const node = nodeMap.get(dept.partyId)!;
+    if (!dept.parentPartyId) {
+      roots.push(node);
+      continue;
+    }
+    const parent = nodeMap.get(dept.parentPartyId);
+    if (parent) parent.children.push(node);
+    else roots.push(node);
+  }
+
+  return roots;
+}
+
+async function upsertDepartment(data: Omit<OrgDepartmentDto, 'id'>): Promise<void> {
+  const query = new Parse.Query('OrgDepartment');
+  query.equalTo('partyId', data.partyId);
+  const existing = await query.first({ useMasterKey: true });
+  const obj = existing ?? new Parse.Object('OrgDepartment');
+  obj.set('partyId', data.partyId);
+  obj.set('name', data.name);
+  obj.set('parentPartyId', data.parentPartyId);
+  obj.set('regionCode', data.regionCode);
+  obj.set('storeId', data.storeId);
+  obj.set('orderIndex', data.orderIndex);
+  obj.set('syncedAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+}
+
+async function upsertMember(data: Omit<OrgMemberDto, 'id'>): Promise<void> {
+  const query = new Parse.Query('OrgMember');
+  query.equalTo('userId', data.userId);
+  const existing = await query.first({ useMasterKey: true });
+  const obj = existing ?? new Parse.Object('OrgMember');
+  obj.set('userId', data.userId);
+  obj.set('name', data.name);
+  obj.set('partyIds', data.partyIds);
+  obj.set('mobile', data.mobile);
+  obj.set('position', data.position);
+  obj.set('syncedAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+}
+
+export async function bootstrapOrgTree(): Promise<void> {
+  const stores = await listStores();
+  let order = 10;
+
+  for (const seed of SEED_DEPARTMENTS) {
+    await upsertDepartment({ ...seed, orderIndex: seed.orderIndex });
+  }
+
+  for (const store of stores) {
+    const partyId = `store_${store.code.toLowerCase()}`;
+    const parentPartyId = SEED_DEPARTMENTS.find((d) => d.regionCode === store.region)?.partyId || 'root';
+    await upsertDepartment({
+      partyId,
+      name: store.name,
+      parentPartyId,
+      regionCode: store.region,
+      storeId: store.id,
+      orderIndex: order++,
+    });
+  }
+
+  console.log('[Org] 组织树种子已同步');
+}
+
+export async function syncOrgFromQiWe(): Promise<{
+  departments: number;
+  members: number;
+  source: 'qiwe' | 'seed';
+  errors: string[];
+}> {
+  const errors: string[] = [];
+
+  try {
+    const contacts = await getInternalContactList();
+    if (contacts.length === 0) {
+      return { departments: 0, members: 0, source: 'seed', errors: ['企微未返回内部联系人,保留本地组织树'] };
+    }
+
+    const userIds = contacts.map((c) => c.userId).filter(Boolean);
+    const details = await getContactDetailsBatch(userIds);
+
+    const partyIds = new Set<string>();
+    for (const contact of contacts) {
+      if (contact.partyId) partyIds.add(contact.partyId);
+    }
+
+    let deptCount = 0;
+    for (const partyId of partyIds) {
+      await upsertDepartment({
+        partyId,
+        name: `部门 ${partyId.slice(-6)}`,
+        parentPartyId: 'root',
+        regionCode: '',
+        storeId: '',
+        orderIndex: deptCount,
+      });
+      deptCount++;
+    }
+
+    let memberCount = 0;
+    for (const contact of contacts) {
+      const detail = details.find((d) => d.userId === contact.userId);
+      await upsertMember({
+        userId: contact.userId,
+        name: detail?.name || contact.userId,
+        partyIds: contact.partyId ? [contact.partyId] : [],
+        mobile: detail?.mobile || '',
+        position: detail?.position || '',
+      });
+      memberCount++;
+    }
+
+    return { departments: deptCount, members: memberCount, source: 'qiwe', errors };
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    errors.push(msg);
+    return { departments: 0, members: 0, source: 'seed', errors };
+  }
+}

+ 8 - 1
backend/backend/src/apps/pc/qiwe/services/organization.service.ts

@@ -77,5 +77,12 @@ export async function getDefaultStore(): Promise<{ storeId: string; storeName: s
 }
 
 export async function resolveOwnerName(ownerId: string): Promise<string> {
-  return ownerId || '';
+  const { getOrgMemberName } = await import('./org-sync.service.js');
+  const name = await getOrgMemberName(ownerId);
+  return name || ownerId || '';
+}
+
+export async function listStoresByRegion(regionCode: string): Promise<StoreDto[]> {
+  const stores = await listStores();
+  return stores.filter((s) => s.region === regionCode);
 }

+ 43 - 0
backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts

@@ -25,6 +25,18 @@ interface SessionInfo {
   sessionType: number;
 }
 
+export interface InternalContactRef {
+  userId: string;
+  partyId: string;
+}
+
+export interface ContactDetail {
+  userId: string;
+  name: string;
+  mobile: string;
+  position: string;
+}
+
 async function callApi<T = any>(method: string, params: Record<string, any> = {}): Promise<QiWeResponse<T>> {
   const resp = await fetch(`${API_BASE}/api/qw/doApi`, {
     method: 'POST',
@@ -102,6 +114,37 @@ export async function getSessionList(sessionType?: number): Promise<SessionInfo[
   return all;
 }
 
+/** 内部联系人分页(企微组织成员) */
+export async function getInternalContactList(limit = 100): Promise<InternalContactRef[]> {
+  if (!TOKEN || !GUID) return [];
+
+  const resp = await callApi<{
+    contactList: InternalContactRef[];
+    hasMore: boolean;
+    currentVersion?: string;
+  }>('/contact/getWxWorkContactList', {
+    guid: GUID,
+    limit,
+  });
+
+  return resp.data.contactList || [];
+}
+
+/** 联系人详情批量(补全姓名等) */
+export async function getContactDetailsBatch(userIds: string[]): Promise<ContactDetail[]> {
+  if (!TOKEN || !GUID || userIds.length === 0) return [];
+
+  try {
+    const resp = await callApi<{ contactList: ContactDetail[] }>(
+      '/contact/batchGetContactInfo',
+      { guid: GUID, userIdList: userIds },
+    );
+    return resp.data.contactList || [];
+  } catch {
+    return userIds.map((userId) => ({ userId, name: userId, mobile: '', position: '' }));
+  }
+}
+
 /** 获取 Token 和 Guid 的可用性 */
 export function getQiWeConfig() {
   return { apiBase: API_BASE, token: TOKEN, guid: GUID };

+ 116 - 7
backend/backend/src/apps/pc/qiwe/utils/group-metrics.util.ts

@@ -1,5 +1,6 @@
 export type ActivityLevel = 'high' | 'medium' | 'low' | 'inactive';
 export type HealthStatus = 'healthy' | 'warning' | 'critical';
+export type HealthGrade = 'S' | 'A' | 'B' | 'C';
 
 export function deriveActivityLevel(messageCountToday: number, memberCount: number): ActivityLevel {
   if (messageCountToday >= 20) return 'high';
@@ -8,24 +9,132 @@ export function deriveActivityLevel(messageCountToday: number, memberCount: numb
   return 'inactive';
 }
 
-export function deriveHealthScore(input: {
-  memberCount: number;
+/** 运营活跃度:文档维护 + 群内运营消息 */
+export function deriveOpsActivityScore(input: {
   messageCountToday: number;
   hasDocument: boolean;
+  documentPinned: boolean;
+  documentInNotice: boolean;
+  activityLevel: ActivityLevel;
   status: string;
 }): number {
   if (input.status === 'dismissed') return 0;
 
-  let score = 55;
-  if (input.memberCount >= 50) score += 15;
-  else if (input.memberCount >= 10) score += 8;
+  let score = 0;
+  if (input.hasDocument) score += 25;
+  if (input.documentPinned) score += 15;
+  if (input.documentInNotice) score += 10;
+  score += Math.min(40, input.messageCountToday * 4);
+  if (input.activityLevel === 'high') score += 10;
+  else if (input.activityLevel === 'medium') score += 5;
 
-  score += Math.min(input.messageCountToday, 15);
-  if (input.hasDocument) score += 10;
+  return Math.max(0, Math.min(100, Math.round(score)));
+}
+
+/** 客户活跃度:成员规模 + 互动 + 进退群 */
+export function deriveCustomerActivityScore(input: {
+  memberCount: number;
+  messageCountToday: number;
+  memberChange24h: number;
+  status: string;
+}): number {
+  if (input.status === 'dismissed') return 0;
+
+  let score = 0;
+  if (input.memberCount >= 50) score += 35;
+  else if (input.memberCount >= 20) score += 28;
+  else if (input.memberCount >= 10) score += 20;
+  else score += Math.min(15, input.memberCount * 2);
+
+  score += Math.min(35, Math.round(input.messageCountToday * 2.5));
+  if (input.memberChange24h > 0) {
+    score += Math.min(20, input.memberChange24h * 5);
+  }
+  if (input.memberCount >= 30 && input.messageCountToday >= 3) score += 10;
 
   return Math.max(0, Math.min(100, Math.round(score)));
 }
 
+/** S/A/B/C:运营 + 客户双维度矩阵评级 */
+export function deriveHealthGrade(opsScore: number, customerScore: number): HealthGrade {
+  if (opsScore >= 70 && customerScore >= 70) return 'S';
+  if (opsScore >= 55 && customerScore >= 55) return 'A';
+  if (opsScore >= 40 && customerScore >= 40) return 'B';
+  return 'C';
+}
+
+export function deriveHealthScoreFromDual(opsScore: number, customerScore: number): number {
+  return Math.round((opsScore + customerScore) / 2);
+}
+
+export function deriveHealthStatusFromGrade(grade: HealthGrade, status: string): HealthStatus {
+  if (status === 'dismissed') return 'critical';
+  if (grade === 'S' || grade === 'A') return 'healthy';
+  if (grade === 'B') return 'warning';
+  return 'critical';
+}
+
+export function deriveHealthMetrics(input: {
+  memberCount: number;
+  messageCountToday: number;
+  memberChange24h: number;
+  hasDocument: boolean;
+  documentPinned: boolean;
+  documentInNotice: boolean;
+  status: string;
+}): {
+  activityLevel: ActivityLevel;
+  opsActivityScore: number;
+  customerActivityScore: number;
+  healthGrade: HealthGrade;
+  healthScore: number;
+  healthStatus: HealthStatus;
+} {
+  const activityLevel = deriveActivityLevel(input.messageCountToday, input.memberCount);
+  const opsActivityScore = deriveOpsActivityScore({
+    messageCountToday: input.messageCountToday,
+    hasDocument: input.hasDocument,
+    documentPinned: input.documentPinned,
+    documentInNotice: input.documentInNotice,
+    activityLevel,
+    status: input.status,
+  });
+  const customerActivityScore = deriveCustomerActivityScore({
+    memberCount: input.memberCount,
+    messageCountToday: input.messageCountToday,
+    memberChange24h: input.memberChange24h,
+    status: input.status,
+  });
+  const healthGrade = deriveHealthGrade(opsActivityScore, customerActivityScore);
+  const healthScore = deriveHealthScoreFromDual(opsActivityScore, customerActivityScore);
+  const healthStatus = deriveHealthStatusFromGrade(healthGrade, input.status);
+
+  return {
+    activityLevel,
+    opsActivityScore,
+    customerActivityScore,
+    healthGrade,
+    healthScore,
+    healthStatus,
+  };
+}
+
+/** @deprecated 保留兼容;新逻辑请用 deriveHealthMetrics */
+export function deriveHealthScore(input: {
+  memberCount: number;
+  messageCountToday: number;
+  hasDocument: boolean;
+  status: string;
+}): number {
+  return deriveHealthMetrics({
+    ...input,
+    memberChange24h: 0,
+    documentPinned: false,
+    documentInNotice: false,
+  }).healthScore;
+}
+
+/** @deprecated 保留兼容 */
 export function deriveHealthStatus(score: number, status: string): HealthStatus {
   if (status === 'dismissed' || score < 50) return 'critical';
   if (score < 70) return 'warning';

+ 56 - 0
backend/backend/src/apps/pc/qiwe/utils/lifecycle.util.ts

@@ -0,0 +1,56 @@
+/** 小区/群生命周期阶段 */
+export type LifecyclePhase =
+  | 'pre_handover'   // 交房前
+  | 'near_handover'  // 临交房
+  | 'post_handover'  // 已交房
+  | 'legacy';        // 老小区
+
+export const LIFECYCLE_LABELS: Record<LifecyclePhase, string> = {
+  pre_handover: '交房前',
+  near_handover: '临交房',
+  post_handover: '已交房',
+  legacy: '老小区',
+};
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const DAYS_NEAR = 90;
+const DAYS_POST = 730;
+
+function parseDeliveryDate(raw: string | Date | null | undefined): Date | null {
+  if (!raw) return null;
+  if (raw instanceof Date) return raw;
+  const text = String(raw).trim();
+  if (!text) return null;
+  if (/^\d{4}-\d{2}$/.test(text)) {
+    return new Date(`${text}-01T00:00:00`);
+  }
+  const d = new Date(text);
+  return Number.isNaN(d.getTime()) ? null : d;
+}
+
+/**
+ * 根据交房日期计算生命周期(运营标准占位,可后续配置化):
+ * - 交房前:距交房 > 90 天
+ * - 临交房:距交房 0~90 天,或交房后 180 天内
+ * - 已交房:交房 180 天 ~ 2 年
+ * - 老小区:交房超过 2 年
+ */
+export function deriveLifecyclePhase(deliveryDate: string | Date | null | undefined): LifecyclePhase {
+  const delivery = parseDeliveryDate(deliveryDate);
+  if (!delivery) return 'pre_handover';
+
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const deliveryDay = new Date(delivery);
+  deliveryDay.setHours(0, 0, 0, 0);
+
+  const daysUntil = Math.round((deliveryDay.getTime() - today.getTime()) / MS_PER_DAY);
+
+  if (daysUntil > DAYS_NEAR) return 'pre_handover';
+  if (daysUntil >= 0) return 'near_handover';
+
+  const daysSince = Math.abs(daysUntil);
+  if (daysSince <= 180) return 'near_handover';
+  if (daysSince <= DAYS_POST) return 'post_handover';
+  return 'legacy';
+}

+ 1 - 1
backend/backend/src/index.ts

@@ -10,8 +10,8 @@ async function bootstrap(): Promise<void> {
   await ensureSchemas();
   console.log('[Setup] Schema 检查完毕');
 
-  await bootstrapAuth();
   await bootstrapOrganization();
+  await bootstrapAuth();
 }
 
 await bootstrap();

+ 173 - 0
backend/backend/src/shared/auth/data-scope.service.ts

@@ -0,0 +1,173 @@
+import { AppError } from '../errors/app-error.js';
+import type { UserDto, UserRole } from '../../apps/pc/auth/services/auth.service.js';
+import type { GroupChatDto } from '../../apps/pc/qiwe/services/groups.service.js';
+import { listStores } from '../../apps/pc/qiwe/services/organization.service.js';
+
+export type ScopeLevel = 'global' | 'region' | 'store';
+
+export interface DataScopeRequest {
+  scopeLevel?: string;
+  storeId?: string;
+  regionCode?: string;
+}
+
+export interface ResolvedDataScope {
+  level: ScopeLevel;
+  storeId?: string;
+  storeIds?: string[];
+  regionCode?: string;
+  storeName?: string;
+  ownerWecomId?: string;
+}
+
+export function filterGroupsByDataScope(groups: GroupChatDto[], scope: ResolvedDataScope): GroupChatDto[] {
+  let result = groups;
+
+  if (scope.level === 'store' && scope.storeId) {
+    result = result.filter((g) => g.storeId === scope.storeId);
+  } else if (scope.level === 'region' && scope.storeIds?.length) {
+    const allowed = new Set(scope.storeIds);
+    result = result.filter((g) => g.storeId && allowed.has(g.storeId));
+  }
+
+  if (scope.ownerWecomId) {
+    result = result.filter((g) => g.ownerId === scope.ownerWecomId);
+  }
+
+  return result;
+}
+
+function parseScopeLevel(raw?: string): ScopeLevel | undefined {
+  if (raw === 'global' || raw === 'region' || raw === 'store') return raw;
+  return undefined;
+}
+
+async function storesInRegion(regionCode: string) {
+  const stores = await listStores();
+  return stores.filter((s) => s.region === regionCode);
+}
+
+async function defaultDirectorScope(): Promise<ResolvedDataScope> {
+  const stores = await listStores();
+  const first = stores[0];
+  if (first) {
+    return { level: 'store', storeId: first.id, storeName: first.name };
+  }
+  return { level: 'global' };
+}
+
+async function defaultSupervisorScope(regionCode: string): Promise<ResolvedDataScope> {
+  const stores = await storesInRegion(regionCode);
+  const first = stores[0];
+  if (first) {
+    return {
+      level: 'store',
+      storeId: first.id,
+      storeName: first.name,
+      regionCode,
+      storeIds: stores.map((s) => s.id),
+    };
+  }
+  return { level: 'region', regionCode, storeIds: [] };
+}
+
+function assertStoreInRegion(storeId: string, stores: Array<{ id: string }>): void {
+  if (!stores.some((s) => s.id === storeId)) {
+    throw new AppError(403, 'SCOPE_DENIED', '无权查看该区域外的门店');
+  }
+}
+
+export async function resolveDataScope(
+  user: UserDto,
+  request: DataScopeRequest = {},
+): Promise<ResolvedDataScope> {
+  const requestedLevel = parseScopeLevel(request.scopeLevel);
+  const requestedStoreId = request.storeId?.trim() || undefined;
+
+  switch (user.role as UserRole) {
+    case 'director': {
+      if (requestedLevel === 'global') {
+        return { level: 'global' };
+      }
+      if (requestedLevel === 'store' && requestedStoreId) {
+        const stores = await listStores();
+        const store = stores.find((s) => s.id === requestedStoreId);
+        if (!store) {
+          throw new AppError(400, 'INVALID_STORE', '门店不存在');
+        }
+        return { level: 'store', storeId: store.id, storeName: store.name };
+      }
+      return defaultDirectorScope();
+    }
+
+    case 'regional_supervisor': {
+      const regionCode = user.regionCode;
+      if (!regionCode) {
+        throw new AppError(403, 'SCOPE_DENIED', '区域督导未绑定区域,请联系管理员');
+      }
+      const regionStores = await storesInRegion(regionCode);
+      const storeIds = regionStores.map((s) => s.id);
+
+      if (requestedLevel === 'global') {
+        throw new AppError(403, 'SCOPE_DENIED', '无权查看全部门店汇总');
+      }
+      if (requestedLevel === 'region') {
+        return { level: 'region', regionCode, storeIds };
+      }
+      if (requestedLevel === 'store' && requestedStoreId) {
+        assertStoreInRegion(requestedStoreId, regionStores);
+        const store = regionStores.find((s) => s.id === requestedStoreId)!;
+        return {
+          level: 'store',
+          storeId: store.id,
+          storeName: store.name,
+          regionCode,
+          storeIds,
+        };
+      }
+      return defaultSupervisorScope(regionCode);
+    }
+
+    case 'store_manager': {
+      if (!user.storeId) {
+        throw new AppError(403, 'SCOPE_DENIED', '账号未绑定门店,请联系管理员');
+      }
+      if (requestedLevel === 'global' || requestedLevel === 'region') {
+        throw new AppError(403, 'SCOPE_DENIED', '无权查看区域或全局汇总');
+      }
+      if (requestedStoreId && requestedStoreId !== user.storeId) {
+        throw new AppError(403, 'SCOPE_DENIED', '无权查看其他门店数据');
+      }
+      return {
+        level: 'store',
+        storeId: user.storeId,
+        storeName: user.storeName,
+      };
+    }
+
+    default:
+      throw new AppError(403, 'SCOPE_DENIED', '无效的用户角色');
+  }
+}
+
+export function scopeToQueryParams(scope: ResolvedDataScope): DataScopeRequest {
+  return {
+    scopeLevel: scope.level,
+    storeId: scope.storeId,
+    regionCode: scope.regionCode,
+  };
+}
+
+export async function listAccessibleStores(user: UserDto) {
+  const all = await listStores();
+  switch (user.role) {
+    case 'director':
+      return all;
+    case 'regional_supervisor':
+      return user.regionCode ? all.filter((s) => s.region === user.regionCode) : [];
+    case 'store_manager':
+      return user.storeId ? all.filter((s) => s.id === user.storeId) : [];
+    default:
+      return [];
+  }
+}

+ 16 - 0
backend/backend/src/shared/auth/require-user.ts

@@ -0,0 +1,16 @@
+import type { Request } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { getUserByToken, type UserDto } from '../../apps/pc/auth/services/auth.service.js';
+import { getBearerToken } from '../../apps/pc/auth/utils/request.util.js';
+
+export async function requireUser(req: Request): Promise<UserDto> {
+  const token = getBearerToken(req);
+  if (!token) {
+    throw new AppError(401, 'UNAUTHORIZED', '未登录');
+  }
+  const user = await getUserByToken(token);
+  if (!user) {
+    throw new AppError(401, 'SESSION_EXPIRED', '登录已过期,请重新登录');
+  }
+  return user;
+}

+ 4 - 0
backend/backend/src/shared/db/organization-bootstrap.ts

@@ -1,4 +1,6 @@
 import { bootstrapStores } from '../../apps/pc/qiwe/services/organization.service.js';
+import { bootstrapCommunities } from '../../apps/pc/qiwe/services/community.service.js';
+import { bootstrapOrgTree } from '../../apps/pc/qiwe/services/org-sync.service.js';
 import {
   migrateGroupBusinessFields,
   clearPlaceholderOrganizationFields,
@@ -7,6 +9,8 @@ import { runOnceMigration } from './migration.util.js';
 
 export async function bootstrapOrganization(): Promise<void> {
   await bootstrapStores();
+  await bootstrapCommunities();
+  await bootstrapOrgTree();
   await runOnceMigration('clear_placeholder_org_fields_v1', clearPlaceholderOrganizationFields);
   await migrateGroupBusinessFields();
 }

+ 30 - 0
backend/backend/src/shared/db/schema-setup.ts

@@ -64,6 +64,10 @@ export async function ensureSchemas(): Promise<void> {
     documentInNotice: 'Boolean',
     messageCountToday: 'Number',
     memberChange24h: 'Number',
+    lifecyclePhase: 'String',
+    healthGrade: 'String',
+    opsActivityScore: 'Number',
+    customerActivityScore: 'Number',
   });
 
   await ensureClassFields('Store', {
@@ -79,6 +83,11 @@ export async function ensureSchemas(): Promise<void> {
     storeName: 'String',
     address: 'String',
     status: 'String',
+    deliveryDate: 'String',
+    lifecyclePhase: 'String',
+    totalHouseholds: 'Number',
+    avgPrice: 'Number',
+    groupCount: 'Number',
   });
 
   await ensureClassFields('GroupMember', {
@@ -113,10 +122,31 @@ export async function ensureSchemas(): Promise<void> {
     role: 'String',
     storeId: 'String',
     storeName: 'String',
+    wecomUserId: 'String',
+    regionCode: 'String',
     department: 'String',
     position: 'String',
   });
 
+  await ensureClassFields('OrgDepartment', {
+    partyId: 'String',
+    name: 'String',
+    parentPartyId: 'String',
+    regionCode: 'String',
+    storeId: 'String',
+    orderIndex: 'Number',
+    syncedAt: 'Date',
+  });
+
+  await ensureClassFields('OrgMember', {
+    userId: 'String',
+    name: 'String',
+    partyIds: 'Array',
+    mobile: 'String',
+    position: 'String',
+    syncedAt: 'Date',
+  });
+
   await ensureClassFields('AuthSession', {
     token: 'String',
     userId: 'String',

+ 198 - 0
backend/backend/tests_python/run_auto_test.mjs

@@ -0,0 +1,198 @@
+const base = 'http://localhost:3101/api';
+const results = [];
+
+function pass(name, detail = '') {
+  results.push({ name, ok: true, detail });
+  console.log(`[PASS] ${name}${detail ? ' - ' + detail : ''}`);
+}
+
+function fail(name, detail = '') {
+  results.push({ name, ok: false, detail });
+  console.log(`[FAIL] ${name}${detail ? ' - ' + detail : ''}`);
+}
+
+async function login(phone) {
+  const r = await fetch(`${base}/auth/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ phone, password: '123456', rememberMe: false }),
+  });
+  const j = await r.json();
+  if (!j.success) throw new Error(`login ${email}: ${JSON.stringify(j.error)}`);
+  return j.data;
+}
+
+async function get(path, token) {
+  const r = await fetch(`${base}${path}`, {
+    headers: token ? { Authorization: `Bearer ${token}` } : {},
+  });
+  let j = null;
+  try { j = await r.json(); } catch { j = null; }
+  return { status: r.status, j };
+}
+
+async function post(path, token, body) {
+  const r = await fetch(`${base}${path}`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      ...(token ? { Authorization: `Bearer ${token}` } : {}),
+    },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  let j = null;
+  try { j = await r.json(); } catch { j = null; }
+  return { status: r.status, j };
+}
+
+console.log('\n=== Auto API Test Suite ===\n');
+
+// 1. Health
+try {
+  const h = await get('/health');
+  if (h.j?.success && h.j.data?.status === 'ok') {
+    pass('health check', `groups=${h.j.data.database?.counts?.groupChat ?? '?'}`);
+  } else fail('health check', JSON.stringify(h.j));
+} catch (e) {
+  fail('health check', e.message);
+}
+
+// 2. Auth required
+try {
+  const g = await get('/qiwe/groups');
+  if (g.status === 401) pass('groups without auth returns 401');
+  else fail('groups without auth returns 401', `status=${g.status}`);
+} catch (e) {
+  fail('groups without auth returns 401', e.message);
+}
+
+// 3. Logins
+let director, supervisor, manager;
+try {
+  director = await login('13800000001');
+  pass('login director', director.user.email);
+} catch (e) { fail('login director', e.message); }
+try {
+  supervisor = await login('13800000002');
+  pass('login supervisor', `region=${supervisor.user.regionCode}`);
+} catch (e) { fail('login supervisor', e.message); }
+try {
+  manager = await login('13800000003');
+  pass('login store_manager', `storeId=${manager.user.storeId}`);
+} catch (e) { fail('login store_manager', e.message); }
+
+if (!director?.token) {
+  console.log('\n=== Summary: aborted (no director token) ===');
+  process.exit(1);
+}
+
+const t = director.token;
+
+// 4. Director scope
+const dirDefault = await get('/dashboard/overview', t);
+if (dirDefault.j?.data?.scope?.level === 'store') {
+  pass('director default scope is store', dirDefault.j.data.scope.storeName || '');
+} else fail('director default scope is store', dirDefault.j?.data?.scope?.level);
+
+const dirGlobal = await get('/dashboard/overview?scopeLevel=global', t);
+if (dirGlobal.j?.data?.scope?.level === 'global') {
+  pass('director can view global overview');
+} else fail('director can view global overview', dirGlobal.j?.data?.scope?.level);
+
+// 5. Supervisor scope
+if (supervisor?.token) {
+  const supGlobal = await get('/dashboard/overview?scopeLevel=global', supervisor.token);
+  if (supGlobal.status === 403 || supGlobal.j?.error?.code === 'SCOPE_DENIED') {
+    pass('supervisor blocked from global');
+  } else fail('supervisor blocked from global', `status=${supGlobal.status}`);
+
+  const supRegion = await get('/dashboard/overview?scopeLevel=region', supervisor.token);
+  if (supRegion.j?.data?.scope?.level === 'region') {
+    pass('supervisor region scope', supRegion.j.data.scope.regionCode);
+  } else fail('supervisor region scope', supRegion.j?.data?.scope?.level);
+}
+
+// 6. Manager scope
+if (manager?.token) {
+  const mgrGlobal = await get('/dashboard/overview?scopeLevel=global', manager.token);
+  if (mgrGlobal.status === 403 || mgrGlobal.j?.error?.code === 'SCOPE_DENIED') {
+    pass('manager blocked from global');
+  } else fail('manager blocked from global', `status=${mgrGlobal.status}`);
+}
+
+// 7. Groups by scope
+const gGlobal = await get('/qiwe/groups?scopeLevel=global', t);
+const gStore = await get('/qiwe/groups', t);
+const globalCount = gGlobal.j?.data?.total ?? 0;
+const storeCount = gStore.j?.data?.total ?? 0;
+pass('groups global count', String(globalCount));
+pass('groups default store count', String(storeCount));
+if (globalCount >= storeCount) {
+  pass('global groups >= store groups');
+} else fail('global groups >= store groups', `${globalCount} vs ${storeCount}`);
+
+// 8. Group fields
+const sample = gGlobal.j?.data?.groups?.[0];
+if (sample?.healthGrade && sample?.lifecyclePhase) {
+  pass('group has healthGrade and lifecyclePhase', `${sample.healthGrade}/${sample.lifecyclePhase}`);
+} else if (globalCount === 0) {
+  pass('group fields skipped', 'no groups in db');
+} else {
+  fail('group has healthGrade and lifecyclePhase', JSON.stringify(sample));
+}
+
+// 9. Communities
+const comm = await get('/qiwe/communities', t);
+const commTotal = comm.j?.data?.total ?? 0;
+if (commTotal > 0) pass('communities list', `total=${commTotal}`);
+else fail('communities list', 'total=0');
+
+// 10. Dashboard lifecycle distribution
+const life = dirDefault.j?.data?.lifecycleDistribution;
+if (Array.isArray(life) && life.length >= 4) {
+  pass('dashboard lifecycleDistribution', `phases=${life.length}`);
+} else fail('dashboard lifecycleDistribution', String(life?.length));
+
+// 11. QiWe sync-groups
+const sync = await post('/qiwe/sync-groups');
+if (sync.j?.success) {
+  const s = sync.j.data?.sync;
+  pass('sync-groups API', `created=${s?.created} updated=${s?.updated} errors=${s?.errors?.length ?? 0}`);
+} else {
+  const msg = sync.j?.error?.message || sync.status;
+  if (String(msg).includes('guid') || String(msg).includes('不在线')) {
+    fail('sync-groups API', `QiWe offline: ${msg}`);
+  } else {
+    fail('sync-groups API', msg);
+  }
+}
+
+// 13. Org sync
+const org = await post('/qiwe/org/sync', t);
+if (org.j?.success) {
+  pass('org sync', `members=${org.j.data?.members} source=${org.j.data?.source}`);
+} else {
+  fail('org sync', org.j?.error?.message || org.status);
+}
+
+// 14. Risk API not implemented yet (expected 404)
+const risk = await get('/risk/work-orders', t);
+if (risk.status === 404) {
+  pass('risk API not implemented (404 expected)', 'frontend still uses mock');
+} else if (risk.j?.success) {
+  pass('risk work-orders API exists', `total=${risk.j.data?.total ?? '?'}`);
+} else {
+  pass('risk API response', `status=${risk.status}`);
+}
+
+// Summary
+console.log('\n=== Summary ===');
+const passed = results.filter((r) => r.ok).length;
+const failed = results.filter((r) => !r.ok);
+console.log(`Passed: ${passed}/${results.length}, Failed: ${failed.length}`);
+if (failed.length) {
+  console.log('\nFailed tests:');
+  failed.forEach((f) => console.log(`  - ${f.name}: ${f.detail}`));
+  process.exit(1);
+}
+process.exit(0);

+ 118 - 0
backend/backend/tests_python/run_p0_scope_test.ps1

@@ -0,0 +1,118 @@
+$base = "http://localhost:3101/api"
+$results = @()
+
+function Test-Case($name, $ok, $detail) {
+  $script:results += [pscustomobject]@{ Test = $name; Pass = $ok; Detail = $detail }
+  $mark = if ($ok) { "PASS" } else { "FAIL" }
+  Write-Host "[$mark] $name - $detail"
+}
+
+function Login($phone) {
+  $body = @{ phone = $phone; password = "123456"; rememberMe = $false } | ConvertTo-Json
+  $r = Invoke-RestMethod -Uri "$base/auth/login" -Method POST -Body $body -ContentType "application/json"
+  if (-not $r.success) { throw "login failed: $email" }
+  return @{ Token = $r.data.token; User = $r.data.user }
+}
+
+function Get-Overview($token, $query = "") {
+  $h = @{ Authorization = "Bearer $token" }
+  $uri = "$base/dashboard/overview"
+  if ($query) { $uri = "$uri?$query" }
+  try {
+    $r = Invoke-RestMethod -Uri $uri -Method GET -Headers $h
+    return @{ Ok = $true; Status = 200; Data = $r.data }
+  } catch {
+    $status = $_.Exception.Response.StatusCode.value__
+    $body = $_.ErrorDetails.Message
+    return @{ Ok = $false; Status = $status; Body = $body }
+  }
+}
+
+function Get-Groups($token) {
+  $h = @{ Authorization = "Bearer $token" }
+  try {
+    $r = Invoke-RestMethod -Uri "$base/qiwe/groups" -Method GET -Headers $h
+    return @{ Ok = $true; Count = @($r.data.groups).Count; Data = $r.data }
+  } catch {
+    $status = $_.Exception.Response.StatusCode.value__
+    return @{ Ok = $false; Status = $status; Body = $_.ErrorDetails.Message }
+  }
+}
+
+Write-Host ""
+Write-Host "=== P0 Scope Tests ==="
+Write-Host ""
+
+try {
+  Invoke-RestMethod -Uri "$base/qiwe/groups" -Method GET | Out-Null
+  Test-Case "groups without auth rejected" $false "no error"
+} catch {
+  $st = $_.Exception.Response.StatusCode.value__
+  Test-Case "groups without auth rejected" ($st -eq 401) "HTTP $st"
+}
+
+$director = Login "13800000001"
+$supervisor = Login "13800000002"
+$manager = Login "13800000003"
+
+Test-Case "director login" ($director.User.role -eq "director") $director.User.email
+Test-Case "supervisor login" ($supervisor.User.role -eq "regional_supervisor") "regionCode=$($supervisor.User.regionCode)"
+Test-Case "manager login" ($manager.User.role -eq "store_manager") "storeId=$($manager.User.storeId)"
+
+$ovDirDefault = Get-Overview $director.Token
+Test-Case "director default scope is store" ($ovDirDefault.Data.scope.level -eq "store") "level=$($ovDirDefault.Data.scope.level)"
+
+$ovDirGlobal = Get-Overview $director.Token "scopeLevel=global"
+Test-Case "director can use global" ($ovDirGlobal.Ok -and $ovDirGlobal.Data.scope.level -eq "global") "level=$($ovDirGlobal.Data.scope.level)"
+
+$ovSupGlobal = Get-Overview $supervisor.Token "scopeLevel=global"
+Test-Case "supervisor blocked from global" (-not $ovSupGlobal.Ok -and $ovSupGlobal.Status -eq 403) "status=$($ovSupGlobal.Status)"
+
+$ovSupRegion = Get-Overview $supervisor.Token "scopeLevel=region"
+$regionOk = $ovSupRegion.Ok -and $ovSupRegion.Data.scope.level -eq "region"
+Test-Case "supervisor can use region" $regionOk "region=$($ovSupRegion.Data.scope.regionCode)"
+
+$ovSupDefault = Get-Overview $supervisor.Token
+Test-Case "supervisor default overview ok" $ovSupDefault.Ok "store=$($ovSupDefault.Data.scope.storeName)"
+
+$ovMgr = Get-Overview $manager.Token
+Test-Case "manager store overview" ($ovMgr.Ok -and $ovMgr.Data.scope.level -eq "store") "storeId=$($ovMgr.Data.scope.storeId)"
+
+$ovMgrGlobal = Get-Overview $manager.Token "scopeLevel=global"
+Test-Case "manager blocked from global" (-not $ovMgrGlobal.Ok -and $ovMgrGlobal.Status -eq 403) "status=$($ovMgrGlobal.Status)"
+
+$commOk = $false
+$commDetail = ""
+try {
+  $h = @{ Authorization = "Bearer $($director.Token)" }
+  $comm = Invoke-RestMethod -Uri "$base/qiwe/communities" -Method GET -Headers $h
+  $total = $comm.data.total
+  $commOk = $total -gt 0
+  $commDetail = "total=$total"
+} catch {
+  $commDetail = $_.Exception.Message
+}
+Test-Case "communities list has data" $commOk $commDetail
+
+$fieldOk = $false
+$fieldDetail = "no groups"
+if ($gMgr.Ok -and $gMgr.Count -gt 0) {
+  $g0 = $gMgr.Data.groups[0]
+  $fieldOk = ($null -ne $g0.healthGrade) -and ($null -ne $g0.lifecyclePhase)
+  $fieldDetail = "grade=$($g0.healthGrade) phase=$($g0.lifecyclePhase)"
+}
+Test-Case "group has healthGrade and lifecyclePhase" $fieldOk $fieldDetail
+
+$lifeCount = @($ovDirDefault.Data.lifecycleDistribution).Count
+Test-Case "overview has lifecycleDistribution" ($lifeCount -gt 0) "count=$lifeCount"
+
+Write-Host ""
+Write-Host "=== Summary ==="
+$passed = ($results | Where-Object { $_.Pass }).Count
+$failed = ($results | Where-Object { -not $_.Pass }).Count
+Write-Host "Passed: $passed / $($results.Count), Failed: $failed"
+if ($failed -gt 0) {
+  $results | Where-Object { -not $_.Pass } | Format-Table -AutoSize
+  exit 1
+}
+exit 0

+ 622 - 0
doc/风控工单-后端开发说明.md

@@ -0,0 +1,622 @@
+# 风控与工单模块 — 后端开发说明
+
+> 文档版本:2026-05-30  
+> 适用范围:`backend/backend` + Angular 前端风控相关页面  
+> 背景:前端「问题分类看板」「预警工单」「风控总览」等页面当前使用 `MockDataService` 假数据;后端尚无对应 API。
+
+---
+
+## 一、现状与缺口
+
+### 1.1 已有能力
+
+| 模块 | 状态 |
+|------|------|
+| `GroupChat` / `Message` / Webhook 收消息 | ✅ 已实现 |
+| 四级权限 + `data-scope.service.ts` | ✅ 已实现 |
+| 总览看板(群、生命周期、健康评级) | ✅ 已接后端 |
+| 登录 / 群列表 / 小区 / 组织 | ✅ 已接后端 |
+
+### 1.2 缺失能力
+
+| 模块 | 状态 |
+|------|------|
+| `RiskKeyword` / `RiskEvent` / `WorkOrder` Parse 表 | ❌ 未建 |
+| `/api/risk/*` 路由与业务服务 | ❌ 未建 |
+| 总览 `pendingWorkOrders`、`riskEventCount` | ❌ 写死为 0 |
+| Webhook 消息 → 关键词扫描 → 自动建单 | ❌ 未做 |
+
+### 1.3 前端仍用 Mock 的页面
+
+| 路由 | 组件 | 当前数据来源 |
+|------|------|--------------|
+| `/workspace/issues` | 问题分类看板 | `mockData.getWorkOrders()` |
+| `/risk-control/work-orders` | 预警工单列表 | `mockData.getWorkOrders()` |
+| `/risk-control` | 风控总览 | `mockData.getRiskEvents()` |
+| `/risk-control/keywords` | 关键词库 | `mockData.getRiskKeywords()` |
+| 工作台「未处理工单」数量 | workspace | `mockData.getWorkOrders()` 过滤 |
+
+**结论**:不是前端故意造假,而是后端接口尚未实现,前端用 Mock 撑 UI 演示。
+
+---
+
+## 二、前端模型对齐(Parse 字段应与此一致)
+
+定义位置:`src/app/core/models/index.ts`
+
+### 2.1 RiskEvent(风控事件)
+
+```typescript
+interface RiskEvent {
+  id: string;
+  groupId: string;           // 对应 GroupChat.roomId
+  groupName: string;
+  communityName: string;
+  type: 'keyword' | 'member_drop' | 'inactive' | 'multi_doc' | 'other';
+  severity: 'high' | 'medium' | 'low';
+  title: string;
+  description: string;
+  keywords?: string[];
+  status: 'pending' | 'processing' | 'resolved' | 'false_alarm';
+  assignedTo?: string;
+  assignedToName?: string;
+  createdAt: Date;
+  resolvedAt?: Date;
+  resolution?: string;
+}
+```
+
+### 2.2 WorkOrder(干预工单)
+
+```typescript
+interface WorkOrder {
+  id: string;
+  riskEventId: string;
+  title: string;
+  description: string;
+  groupName: string;
+  communityName: string;
+  assignedTo: string;
+  assignedToName: string;
+  priority: 'high' | 'medium' | 'low';
+  status: 'open' | 'in_progress' | 'resolved' | 'closed';
+  createdAt: Date;
+  deadline: Date;
+  resolvedAt?: Date;
+  resolution?: string;
+  resolutionLog: string[];
+}
+```
+
+### 2.3 RiskKeyword(风险关键词)
+
+```typescript
+interface RiskKeyword {
+  id: string;
+  word: string;
+  category: 'complaint' | 'competitor' | 'sensitive' | 'price' | 'other';
+  severity: 'high' | 'medium' | 'low';
+  enabled: boolean;
+  createdAt: Date;
+}
+```
+
+---
+
+## 三、Parse 数据库表设计
+
+在 `src/shared/db/schema-setup.ts` 的 `ensureSchemas()` 中追加。
+
+### 3.1 RiskKeyword(风险关键词库)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `word` | String | 关键词,如「欧派」「投诉」 |
+| `category` | String | `complaint` / `competitor` / `sensitive` / `price` / `other` |
+| `severity` | String | `high` / `medium` / `low` |
+| `enabled` | Boolean | 是否启用 |
+| `createdBy` | String | 创建人 AppUser.id(可选) |
+
+**启动种子(与前端 Mock 一致)**
+
+| word | category | severity |
+|------|----------|----------|
+| 投诉 | complaint | high |
+| 欧派 | competitor | medium |
+| 索菲亚 | competitor | medium |
+| 退群 | sensitive | high |
+| 太贵了 | price | low |
+
+---
+
+### 3.2 RiskEvent(风险/预警事件)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `roomId` | String | 群 ID,关联 `GroupChat.roomId` |
+| `groupName` | String | 冗余群名 |
+| `communityId` | String | 可选 |
+| `communityName` | String | 冗余小区名 |
+| `storeId` | String | **权限过滤**,从 GroupChat 复制 |
+| `type` | String | `keyword` / `member_drop` / `inactive` / `multi_doc` / `other` |
+| `severity` | String | `high` / `medium` / `low` |
+| `title` | String | 预警标题 |
+| `description` | String | 详情 |
+| `keywords` | Array | 命中的词列表 |
+| `messageId` | String | 触发消息 ID(可选) |
+| `status` | String | `pending` / `processing` / `resolved` / `false_alarm` |
+| `assignedTo` | String | AppUser.id |
+| `assignedToName` | String | 冗余姓名 |
+| `workOrderId` | String | 关联 WorkOrder objectId |
+| `resolvedAt` | Date | 结案时间 |
+| `resolution` | String | 处理说明 |
+| `guid` | String | 企微实例 GUID |
+
+---
+
+### 3.3 WorkOrder(干预工单)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `riskEventId` | String | 关联 RiskEvent objectId |
+| `roomId` | String | 群 ID |
+| `groupName` | String | 群名 |
+| `communityName` | String | 小区名 |
+| `storeId` | String | **权限过滤** |
+| `title` | String | 工单标题 |
+| `description` | String | 描述 |
+| `assignedTo` | String | AppUser.id |
+| `assignedToName` | String | 负责人姓名 |
+| `priority` | String | `high` / `medium` / `low` |
+| `status` | String | `open` / `in_progress` / `resolved` / `closed` |
+| `deadline` | Date | 截止时间 |
+| `resolvedAt` | Date | 完成时间 |
+| `resolution` | String | 处理结果 |
+| `resolutionLog` | Array | 操作日志(字符串数组) |
+| `createdBy` | String | 系统或操作人 AppUser.id |
+
+---
+
+### 3.4 RiskConfig(阈值配置,建议有)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `key` | String | 如 `member_drop_24h` |
+| `value` | Number | 如 `8`(24h 退群 ≥8 触发) |
+| `enabled` | Boolean | 是否启用 |
+| `description` | String | 说明 |
+
+**默认种子**
+
+| key | value | 说明 |
+|-----|-------|------|
+| `member_drop_24h` | 8 | 24 小时内退群人数阈值 |
+| `inactive_days` | 5 | 连续无互动天数 |
+
+---
+
+## 四、API 设计
+
+路由挂载:`app.use('/api/risk', riskRoutes)`  
+目录建议:`src/apps/pc/risk/`
+
+**除 Webhook 内部调用外,以下接口均需 `Authorization: Bearer <token>`。**
+
+### 4.1 关键词库
+
+| 方法 | 路径 | 说明 | 角色 |
+|------|------|------|------|
+| GET | `/api/risk/keywords` | 列表,`?category=&enabled=true` | 总监/督导/店长 |
+| POST | `/api/risk/keywords` | 新增 | 总监/督导 |
+| PATCH | `/api/risk/keywords/:id` | 修改 severity / enabled | 总监/督导 |
+| DELETE | `/api/risk/keywords/:id` | 删除 | 总监 |
+
+**GET 响应示例**
+
+```json
+{
+  "success": true,
+  "data": {
+    "keywords": [
+      {
+        "id": "xxx",
+        "word": "欧派",
+        "category": "competitor",
+        "severity": "medium",
+        "enabled": true,
+        "createdAt": "2026-05-30T00:00:00.000Z"
+      }
+    ],
+    "total": 5
+  },
+  "error": null
+}
+```
+
+**POST body**
+
+```json
+{
+  "word": "欧派",
+  "category": "competitor",
+  "severity": "medium"
+}
+```
+
+---
+
+### 4.2 风险事件(风控总览)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/risk/events` | 列表 + 筛选 |
+| GET | `/api/risk/events/:id` | 详情 |
+| PATCH | `/api/risk/events/:id` | 更新状态 / 指派 / 误报 |
+
+**查询参数**
+
+| 参数 | 说明 |
+|------|------|
+| `severity` | `high` / `medium` / `low` |
+| `type` | `keyword` / `member_drop` / `inactive` / `multi_doc` / `other` |
+| `status` | `pending` / `processing` / `resolved` / `false_alarm` |
+| `scopeLevel` | `global` / `region` / `store`(与群列表一致) |
+| `storeId` | `scopeLevel=store` 时 |
+
+**PATCH body 示例**
+
+```json
+{
+  "status": "processing",
+  "assignedTo": "<AppUserId>",
+  "resolution": "已联系群主处理"
+}
+```
+
+**GET 响应示例**
+
+```json
+{
+  "success": true,
+  "data": {
+    "events": [
+      {
+        "id": "re1",
+        "groupId": "10865515454476837",
+        "groupName": "xxx业主群",
+        "communityName": "xxx",
+        "type": "keyword",
+        "severity": "high",
+        "title": "群内出现竞品名称提及",
+        "description": "命中关键词:欧派",
+        "keywords": ["欧派"],
+        "status": "pending",
+        "assignedToName": "李店长",
+        "createdAt": "2026-05-30T09:00:00.000Z"
+      }
+    ],
+    "total": 1,
+    "scope": { "level": "store", "storeId": "...", "storeName": "上海总部" }
+  }
+}
+```
+
+---
+
+### 4.3 工单(问题分类看板 / 预警工单)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/risk/work-orders` | 看板 / 列表 |
+| GET | `/api/risk/work-orders/:id` | 详情 |
+| POST | `/api/risk/work-orders` | 手工建单(可选) |
+| PATCH | `/api/risk/work-orders/:id` | 改状态、写处理记录 |
+| POST | `/api/risk/work-orders/:id/resolve` | 标记已解决 |
+| POST | `/api/risk/work-orders/:id/close` | 关闭工单 |
+
+**查询参数**
+
+| 参数 | 说明 |
+|------|------|
+| `status` | `open` / `in_progress` / `resolved` / `closed` |
+| `priority` | `high` / `medium` / `low` |
+| `q` | 标题 / 群名搜索 |
+| `scopeLevel` / `storeId` | 数据范围(服务端校验) |
+
+**GET 响应示例**
+
+```json
+{
+  "success": true,
+  "data": {
+    "workOrders": [
+      {
+        "id": "wo1",
+        "riskEventId": "re1",
+        "title": "群内出现竞品名称提及",
+        "description": "系统自动检测到异常,请相关人员进行确认处理。",
+        "groupName": "xxx业主群",
+        "communityName": "xxx",
+        "assignedTo": "userId",
+        "assignedToName": "李店长",
+        "priority": "high",
+        "status": "open",
+        "createdAt": "2026-05-30T09:00:00.000Z",
+        "deadline": "2026-06-02T09:00:00.000Z",
+        "resolutionLog": ["系统自动生成工单"]
+      }
+    ],
+    "total": 3,
+    "counts": {
+      "open": 1,
+      "in_progress": 2,
+      "resolved": 4,
+      "closed": 0
+    },
+    "scope": { "level": "store", "storeId": "...", "storeName": "上海总部" }
+  }
+}
+```
+
+**POST resolve / close**
+
+无 body 或可选:
+
+```json
+{
+  "resolution": "已联系当事人处理完毕"
+}
+```
+
+响应更新 `status`、`resolvedAt`、`resolution`、`resolutionLog`。
+
+---
+
+### 4.4 风控统计(可选)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/risk/overview` | 待处理数、severity/type 分布 |
+
+供 `/risk-control` 风控总览页图表使用;也可合并进 `GET /api/risk/events` 的聚合字段。
+
+**响应示例**
+
+```json
+{
+  "success": true,
+  "data": {
+    "pendingEvents": 5,
+    "openWorkOrders": 3,
+    "severityDistribution": { "high": 2, "medium": 2, "low": 1 },
+    "typeDistribution": { "keyword": 3, "member_drop": 1, "inactive": 1 },
+    "scope": { "level": "store", "storeId": "..." }
+  }
+}
+```
+
+---
+
+### 4.5 修改现有总览接口
+
+**`GET /api/dashboard/overview`**
+
+将 `dashboard.service.ts` 中写死的:
+
+```typescript
+riskEventCount: 0,
+pendingWorkOrders: 0,
+recentRiskEvents: [],
+```
+
+改为真实查询(在当前 `scope` 下):
+
+| 字段 | 计算规则 |
+|------|----------|
+| `pendingWorkOrders` | `WorkOrder.status in (open, in_progress)` 计数 |
+| `riskEventCount` | `RiskEvent.status in (pending, processing)` 计数 |
+| `recentRiskEvents` | 最近 5 条 RiskEvent(可选,需扩展 DTO) |
+
+---
+
+## 五、后端目录与文件清单
+
+```
+backend/backend/src/apps/pc/risk/
+├── models/
+│   └── risk.types.ts              # DTO,对齐前端 interface
+├── services/
+│   ├── keyword.service.ts         # 关键词 CRUD + 种子
+│   ├── risk-event.service.ts      # 预警 CRUD、创建、列表
+│   ├── work-order.service.ts      # 工单 CRUD、状态机、resolutionLog
+│   ├── risk-scan.service.ts       # 消息扫描、阈值检测(阶段 B)
+│   └── risk-scope.util.ts         # 按 storeId/region/owner 过滤
+├── controllers/
+│   ├── keyword.controller.ts
+│   ├── risk-event.controller.ts
+│   └── work-order.controller.ts
+└── routes/
+    └── risk.routes.ts
+```
+
+**需修改的现有文件**
+
+| 文件 | 变更 |
+|------|------|
+| `src/shared/db/schema-setup.ts` | 新增 4 表字段 |
+| `src/index.ts` 或 `organization-bootstrap` | 关键词种子、可选演示工单种子 |
+| `src/apps/pc/app.ts` | `app.use('/api/risk', riskRoutes)` |
+| `src/apps/pc/dashboard/services/dashboard.service.ts` | 真实 pendingWorkOrders / riskEventCount |
+| `src/apps/pc/qiwe/services/webhook.service.ts` | 消息入库后调用 risk-scan(阶段 B) |
+
+**参考实现(逻辑可迁移,字段需按本文对齐)**
+
+`lami-base-v1/backend/src/apps/pc/risk/services/risk.service.ts`
+
+---
+
+## 六、权限与数据范围
+
+复用已有模块:
+
+| 模块 | 路径 |
+|------|------|
+| 鉴权 | `src/shared/auth/require-user.ts` |
+| 范围解析 | `src/shared/auth/data-scope.service.ts` |
+
+**过滤规则**
+
+1. `RiskEvent`、`WorkOrder` 创建时从 `GroupChat` 复制 `storeId`、`groupName`、`communityName`。
+2. 列表查询按 scope 过滤:
+   - `global`:不过滤 store
+   - `region`:`storeId in scope.storeIds`
+   - `store`:`storeId = scope.storeId`
+3. `single_group`:在 store 基础上,可选仅 `roomId` 属于 `ownerWecomId` 对应群(与群列表一致)。
+
+| 角色 | 可见工单范围 |
+|------|--------------|
+| 总监 | 全局 / 区域 / 门店(可选) |
+| 区域督导 | 区域内门店 |
+| 店长 | 本店 |
+| 单群 | 本店 + 本人负责群(可选) |
+
+**关键词管理**:建议仅 `director`、`regional_supervisor` 可写;`store_manager` 只读。
+
+---
+
+## 七、自动生成工单(与 QiWe 对接)
+
+### 7.1 关键词命中(阶段 B,优先)
+
+```
+Webhook 收到群消息
+  → 保存 Message(已有)
+  → riskScanService.scanText(content, roomId, messageId)
+  → 命中 RiskKeyword(enabled=true)
+  → createRiskEvent(type=keyword, severity=关键词级别)
+  → createWorkOrder(status=open)
+  → RiskEvent.workOrderId 回写
+  → resolutionLog 追加「系统自动生成工单」
+```
+
+### 7.2 人数骤降(阶段 C)
+
+```
+Webhook 成员退群 / 定时任务
+  → 更新 GroupChat.memberChange24h
+  → 超过 RiskConfig.member_drop_24h
+  → createRiskEvent(type=member_drop)
+  → createWorkOrder
+```
+
+### 7.3 长期无互动(阶段 C)
+
+```
+定时任务(如每小时)
+  → 群今日消息 = 0 且连续 inactive_days 天
+  → createRiskEvent(type=inactive)
+  → createWorkOrder
+```
+
+### 7.4 默认指派规则
+
+| 场景 | assignee |
+|------|----------|
+| `GroupChat.ownerId` 匹配某 `AppUser.wecomUserId` | 该单群运营 |
+| 否则 | 该群 `storeId` 对应店长(`role=store_manager`) |
+| 无店长 | 区域督导(`regionCode` 匹配 `Store.region`) |
+
+**deadline 建议**:创建时间 + 72 小时(可配置)。
+
+---
+
+## 八、前端对接清单
+
+后端完成后,前端需修改:
+
+| 前端文件 | 变更 |
+|----------|------|
+| 新建 `src/app/core/services/api/risk-api.service.ts` | 封装上述 API |
+| `workspace-issues.component.ts` | 改调 `risk-api.getWorkOrders()` |
+| `work-orders.component.ts` | 列表 + resolve/close 调 API |
+| `risk-dashboard.component.ts` | 改调 `risk-api.getEvents()` |
+| `keywords.component.ts` | 改调 keywords CRUD |
+| `workspace.component.ts` | 待处理工单数量改调 API |
+| `dashboard.component.ts` | 已接 API 时 `pendingWorkOrders` 来自 overview |
+
+**无数据时**:显示 EmptyState,不再回退 Mock。
+
+---
+
+## 九、分阶段实施
+
+### 阶段 A — 最小可用(替换假数据,约 1~2 天)
+
+- [ ] Parse 表 RiskKeyword / RiskEvent / WorkOrder + schema
+- [ ] 关键词 CRUD + 启动种子
+- [ ] `GET /api/risk/work-orders`、`GET /api/risk/events`(只读)+ 数据范围
+- [ ] 种子或脚本插入 2~3 条演示工单
+- [ ] `dashboard.service.ts` 真实 `pendingWorkOrders`
+- [ ] 前端 3 个页面改调 API
+
+**交付标准**:看板无 Mock;没数据时空状态。
+
+### 阶段 B — 业务闭环(约 3~5 天)
+
+- [ ] 工单 PATCH / resolve / close + resolutionLog
+- [ ] Webhook 关键词扫描自动建单
+- [ ] 预警 PATCH(误报、指派)
+- [ ] `GET /api/risk/overview` 统计
+
+### 阶段 C — 完整风控(后续)
+
+- [ ] RiskConfig 阈值 + 退群 / 无互动检测
+- [ ] 企微强提醒(QiWe 发消息 API)
+- [ ] 案例知识库 `RiskCase`(二期)
+
+---
+
+## 十、验收用例
+
+| # | 用例 | 预期 |
+|---|------|------|
+| 1 | 未登录 `GET /api/risk/work-orders` | 401 |
+| 2 | 总监 `scopeLevel=global` | 返回全部可见工单 |
+| 3 | 店长登录 | 仅本店;带其他 storeId 403 |
+| 4 | 总览 `pendingWorkOrders` | 与 open+in_progress 工单数一致 |
+| 5 | 看板三列计数 | 与 API `counts` 一致 |
+| 6 | Webhook 发送含「欧派」消息 | 自动 1 条 RiskEvent + 1 条 WorkOrder |
+| 7 | POST resolve | status→resolved,resolutionLog 追加 |
+| 8 | 前端问题分类看板 | 不再出现「王运营」等 Mock 固定人名(除非真实指派) |
+
+---
+
+## 十一、与业务文档对应关系
+
+| 业务模块 | 功能清单描述 | 本文覆盖 |
+|----------|--------------|----------|
+| 模块 5 群风控 | 风险关键词库 | §3.1、§4.1 |
+| 模块 5 | 敏感词命中预警 | §7.1 |
+| 模块 5 | 人数骤降阈值异常 | §3.4、§7.2 |
+| 模块 5 | 预警自动生成工单 | §7.1 |
+| 模块 5 | 工单处理闭环 | §4.3 resolve/close |
+| 模块 10 | 问题分类看板 | §4.3、§8 |
+| 模块 8 数据看板 | 待处理工单指标 | §4.5 |
+
+---
+
+## 十二、相关代码入口速查
+
+```
+鉴权           → src/shared/auth/require-user.ts
+数据范围       → src/shared/auth/data-scope.service.ts
+Webhook 消息   → src/apps/pc/qiwe/services/webhook.service.ts
+总览(待改)   → src/apps/pc/dashboard/services/dashboard.service.ts
+前端工单 Mock  → src/app/core/services/mock-data.service.ts (generateWorkOrders)
+前端模型       → src/app/core/models/index.ts (RiskEvent, WorkOrder, RiskKeyword)
+旧项目参考     → lami-base-v1/backend/src/apps/pc/risk/
+```
+
+---
+
+*文档维护:风控模块开发完成后请同步更新验收用例与 API 示例。*

+ 4 - 0
src/app/core/auth/auth-api.types.ts

@@ -19,6 +19,8 @@ export interface ApiUserDto {
   role: UserRole;
   storeId: string;
   storeName: string;
+  wecomUserId?: string;
+  regionCode?: string;
   department: string;
   position: string;
   createdAt: string;
@@ -33,6 +35,8 @@ export function mapApiUser(dto: ApiUserDto): User {
     role: dto.role,
     storeId: dto.storeId,
     storeName: dto.storeName,
+    wecomUserId: dto.wecomUserId,
+    regionCode: dto.regionCode,
     department: dto.department,
     position: dto.position,
     createdAt: new Date(dto.createdAt),

+ 22 - 25
src/app/core/auth/auth.service.ts

@@ -4,7 +4,7 @@ import { MockDataService } from '../services/mock-data.service';
 import { StorageService } from '../services/storage.service';
 import { ApiClientService } from '../services/api/api-client.service';
 import { environment } from '../../../environments/environment';
-import type { User, UserRole } from '../models';
+import type { User } from '../models';
 import {
   AUTH_SESSION_KEY,
   type AuthSessionPayload,
@@ -30,31 +30,28 @@ export class AuthService {
   }
 
   async login(
-    email: string,
+    phone: string,
     password: string,
-    role: string,
     rememberMe: boolean,
   ): Promise<{ ok: boolean; error?: string }> {
     if (environment.useBackendApi) {
-      return this.loginViaApi(email, password, role, rememberMe);
+      return this.loginViaApi(phone, password, rememberMe);
     }
-    return this.loginViaMock(email, password, role, rememberMe);
+    return this.loginViaMock(phone, password, rememberMe);
   }
 
   async register(
     name: string,
-    email: string,
     phone: string,
     password: string,
-    role: UserRole = 'single_group',
+    email: string,
   ): Promise<{ ok: boolean; error?: string }> {
     if (environment.useBackendApi) {
       const result = await this.api.postResult<{ user: unknown }>('/auth/register', {
         name,
-        email,
         phone,
         password,
-        role,
+        email,
       });
       if (!result.ok) {
         return { ok: false, error: result.error ?? '注册失败' };
@@ -62,14 +59,18 @@ export class AuthService {
       return { ok: true };
     }
 
-    const ok = this.mockData.registerUser({ name, email, phone, password, role });
-    return ok ? { ok: true } : { ok: false, error: '注册失败,该邮箱可能已被注册' };
+    const ok = this.mockData.registerUser({ name, phone, password, email });
+    return ok ? { ok: true } : { ok: false, error: '注册失败,该手机号或邮箱可能已被注册' };
   }
 
-  async resetPassword(email: string): Promise<{ ok: boolean; error?: string }> {
+  async resetPasswordByEmail(
+    email: string,
+    newPassword: string,
+  ): Promise<{ ok: boolean; error?: string }> {
     if (environment.useBackendApi) {
       const result = await this.api.postResult<{ message: string }>('/auth/forgot-password', {
         email: email.trim(),
+        newPassword,
       });
       if (!result.ok) {
         return { ok: false, error: result.error ?? '操作失败' };
@@ -77,8 +78,8 @@ export class AuthService {
       return { ok: true };
     }
 
-    const user = this.mockData.getUserByEmail(email);
-    return user ? { ok: true } : { ok: false, error: '该邮箱未注册,请检查后重试' };
+    const ok = this.mockData.resetPasswordByEmail(email, newPassword);
+    return ok ? { ok: true } : { ok: false, error: '该邮箱未注册,请检查后重试' };
   }
 
   async updateProfile(patch: {
@@ -181,20 +182,18 @@ export class AuthService {
   }
 
   private async loginViaApi(
-    email: string,
+    phone: string,
     password: string,
-    role: string,
     rememberMe: boolean,
   ): Promise<{ ok: boolean; error?: string }> {
     const result = await this.api.postResult<LoginResponseDto>('/auth/login', {
-      email,
+      phone,
       password,
-      role,
       rememberMe,
     });
 
     if (!result.ok || !result.data) {
-      return { ok: false, error: result.error ?? '邮箱或密码错误,请重试' };
+      return { ok: false, error: result.error ?? '手机号或密码错误,请重试' };
     }
 
     const user = mapApiUser(result.data.user);
@@ -204,16 +203,14 @@ export class AuthService {
   }
 
   private loginViaMock(
-    email: string,
+    phone: string,
     password: string,
-    role: string,
     rememberMe: boolean,
   ): { ok: boolean; error?: string } {
-    const user = this.mockData.getUserByEmail(email);
-    if (!user) return { ok: false, error: '邮箱或密码错误,请重试' };
-    if (user.role !== role) return { ok: false, error: '角色不匹配,请选择正确的登录角色' };
+    const user = this.mockData.getUserByPhone(phone);
+    if (!user) return { ok: false, error: '手机号或密码错误,请重试' };
     if (!this.mockData.verifyPassword(user.id, password)) {
-      return { ok: false, error: '邮箱或密码错误,请重试' };
+      return { ok: false, error: '手机号或密码错误,请重试' };
     }
 
     this.authStore.login(user, rememberMe);

+ 16 - 0
src/app/core/models/index.ts

@@ -1,4 +1,12 @@
+import type { LifecyclePhase, HealthGrade } from './lifecycle.constants';
 import type { UserRole } from './role.constants';
+export type { LifecyclePhase, HealthGrade } from './lifecycle.constants';
+export {
+  LIFECYCLE_LABELS,
+  LIFECYCLE_PHASES,
+  HEALTH_GRADES,
+  HEALTH_GRADE_LABELS,
+} from './lifecycle.constants';
 export type { UserRole } from './role.constants';
 export {
   USER_ROLES,
@@ -6,6 +14,7 @@ export {
   ROLE_OPTIONS,
   getRoleLabel,
   isDirector,
+  isRegionalSupervisor,
   ROUTE_ROLES,
 } from './role.constants';
 
@@ -21,6 +30,8 @@ export interface User {
   role: UserRole;
   storeId: string;
   storeName: string;
+  wecomUserId?: string;
+  regionCode?: string;
   department: string;
   position: string;
   createdAt: Date;
@@ -46,6 +57,7 @@ export interface Community {
   totalHouseholds: number;
   avgPrice: number;
   deliveryDate: string;
+  lifecyclePhase: LifecyclePhase;
   storeId: string;
   storeName: string;
   coverageRate: number;
@@ -68,6 +80,10 @@ export interface Group {
   createdAt: Date;
   healthScore: number;
   healthStatus: 'healthy' | 'warning' | 'critical';
+  healthGrade: HealthGrade;
+  opsActivityScore: number;
+  customerActivityScore: number;
+  lifecyclePhase: LifecyclePhase;
   hasDocument: boolean;
   documentPinned: boolean;
   documentInNotice: boolean;

+ 30 - 0
src/app/core/models/lifecycle.constants.ts

@@ -0,0 +1,30 @@
+export type LifecyclePhase =
+  | 'pre_handover'
+  | 'near_handover'
+  | 'post_handover'
+  | 'legacy';
+
+export const LIFECYCLE_LABELS: Record<LifecyclePhase, string> = {
+  pre_handover: '交房前',
+  near_handover: '临交房',
+  post_handover: '已交房',
+  legacy: '老小区',
+};
+
+export const LIFECYCLE_PHASES: LifecyclePhase[] = [
+  'pre_handover',
+  'near_handover',
+  'post_handover',
+  'legacy',
+];
+
+export type HealthGrade = 'S' | 'A' | 'B' | 'C';
+
+export const HEALTH_GRADES: HealthGrade[] = ['S', 'A', 'B', 'C'];
+
+export const HEALTH_GRADE_LABELS: Record<HealthGrade, string> = {
+  S: 'S 级',
+  A: 'A 级',
+  B: 'B 级',
+  C: 'C 级',
+};

+ 8 - 6
src/app/core/models/role.constants.ts

@@ -1,18 +1,16 @@
-/** 系统角色:总监 / 区域督导 / 店长 / 单群 */
-export type UserRole = 'director' | 'regional_supervisor' | 'store_manager' | 'single_group';
+/** 系统角色:总监 / 区域督导 / 店长(由后台分配,注册与登录时不选择) */
+export type UserRole = 'director' | 'regional_supervisor' | 'store_manager';
 
 export const USER_ROLES: UserRole[] = [
   'director',
   'regional_supervisor',
   'store_manager',
-  'single_group',
 ];
 
 export const ROLE_LABELS: Record<UserRole, string> = {
   director: '总监',
   regional_supervisor: '区域督导',
   store_manager: '店长',
-  single_group: '单群',
 };
 
 export const ROLE_OPTIONS = USER_ROLES.map((value) => ({
@@ -28,16 +26,20 @@ export function isDirector(role: UserRole | string | null | undefined): boolean
   return role === 'director';
 }
 
+export function isRegionalSupervisor(role: UserRole | string | null | undefined): boolean {
+  return role === 'regional_supervisor';
+}
+
 /** 路由与侧栏菜单权限 */
 export const ROUTE_ROLES = {
   directorOnly: ['director'] as UserRole[],
   dashboard: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   workspace: USER_ROLES,
   groupOps: USER_ROLES,
-  docs: ['director', 'regional_supervisor', 'store_manager', 'single_group'] as UserRole[],
+  docs: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   compliance: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   risk: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
-  content: ['director', 'regional_supervisor', 'store_manager', 'single_group'] as UserRole[],
+  content: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   acquisition: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   dataEntry: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],
   reports: ['director', 'regional_supervisor', 'store_manager'] as UserRole[],

+ 16 - 16
src/app/core/services/api/dashboard-api.service.ts

@@ -1,6 +1,7 @@
 import { Injectable, inject } from '@angular/core';
 import { ApiClientService } from './api-client.service';
 import type { GroupChatDto } from './qiwe-api.service';
+import type { StoreDto } from './qiwe-api.service';
 
 export interface DashboardStatsDto {
   totalGroups: number;
@@ -14,31 +15,30 @@ export interface DashboardStatsDto {
   conversionRate: number;
 }
 
-export interface ScopeUserDto {
-  id: string;
-  name: string;
-  email: string;
-  phone: string;
-  role: string;
-  storeId: string;
-  storeName: string;
-  department: string;
-  position: string;
-  createdAt: string;
+export interface ResolvedScopeDto {
+  level: 'global' | 'region' | 'store';
+  storeId?: string;
+  storeIds?: string[];
+  regionCode?: string;
+  storeName?: string;
 }
 
 export interface DashboardOverviewDto {
   stats: DashboardStatsDto;
   activityDistribution: { high: number; medium: number; low: number; inactive: number };
+  lifecycleDistribution: Array<{ phase: string; label: string; count: number }>;
+  healthGradeDistribution: Record<string, number>;
   storeComparison: Array<{ storeName: string; groupCount: number }>;
   topGroups: GroupChatDto[];
   recentRiskEvents: [];
-  scopeUsers: ScopeUserDto[];
+  stores: StoreDto[];
+  scope: ResolvedScopeDto;
+  viewer: { id: string; name: string; role: string; storeId: string; storeName: string; regionCode?: string };
 }
 
 export interface DashboardScopeQuery {
-  role?: string;
-  entityId?: string;
+  scopeLevel?: 'global' | 'region' | 'store';
+  storeId?: string;
 }
 
 @Injectable({ providedIn: 'root' })
@@ -47,8 +47,8 @@ export class DashboardApiService {
 
   getOverview(scope: DashboardScopeQuery = {}) {
     const params = new URLSearchParams();
-    if (scope.role) params.set('role', scope.role);
-    if (scope.entityId) params.set('entityId', scope.entityId);
+    if (scope.scopeLevel) params.set('scopeLevel', scope.scopeLevel);
+    if (scope.storeId) params.set('storeId', scope.storeId);
     const query = params.toString();
     const path = query ? `/dashboard/overview?${query}` : '/dashboard/overview';
     return this.api.getResult<DashboardOverviewDto>(path);

+ 58 - 1
src/app/core/services/api/qiwe-api.service.ts

@@ -1,6 +1,7 @@
 import { Injectable, inject } from '@angular/core';
 import { ApiClientService } from './api-client.service';
 import type { Group } from '../../models';
+import type { LifecyclePhase, HealthGrade } from '../../models';
 
 export interface GroupChatDto {
   id: string;
@@ -19,6 +20,10 @@ export interface GroupChatDto {
   activityLevel: string;
   healthScore: number;
   healthStatus: string;
+  healthGrade: HealthGrade;
+  opsActivityScore: number;
+  customerActivityScore: number;
+  lifecyclePhase: LifecyclePhase;
   hasDocument: boolean;
   documentPinned: boolean;
   documentInNotice: boolean;
@@ -34,10 +39,33 @@ export interface StoreDto {
   region: string;
 }
 
+export interface CommunityDto {
+  id: string;
+  name: string;
+  address: string;
+  deliveryDate: string;
+  lifecyclePhase: LifecyclePhase;
+  totalHouseholds: number;
+  avgPrice: number;
+  storeId: string;
+  storeName: string;
+  groupCount: number;
+  status: string;
+}
+
+export interface LifecycleSummaryDto {
+  phase: LifecyclePhase;
+  label: string;
+  count: number;
+  communities: CommunityDto[];
+}
+
 export interface GroupListFilters {
   storeId?: string;
   activityLevel?: string;
   healthStatus?: string;
+  healthGrade?: string;
+  lifecyclePhase?: string;
   hasDocument?: string;
   q?: string;
 }
@@ -59,6 +87,8 @@ export class QiweApiService {
     if (filters.storeId) params.set('storeId', filters.storeId);
     if (filters.activityLevel) params.set('activityLevel', filters.activityLevel);
     if (filters.healthStatus) params.set('healthStatus', filters.healthStatus);
+    if (filters.healthGrade) params.set('healthGrade', filters.healthGrade);
+    if (filters.lifecyclePhase) params.set('lifecyclePhase', filters.lifecyclePhase);
     if (filters.hasDocument) params.set('hasDocument', filters.hasDocument);
     if (filters.q) params.set('q', filters.q);
     const query = params.toString();
@@ -66,10 +96,33 @@ export class QiweApiService {
     return this.api.getResult<{ groups: GroupChatDto[]; total: number; stores: StoreDto[] }>(path);
   }
 
+  listCommunities(lifecyclePhase?: string) {
+    const params = new URLSearchParams();
+    if (lifecyclePhase) params.set('lifecyclePhase', lifecyclePhase);
+    const query = params.toString();
+    const path = query ? `/qiwe/communities?${query}` : '/qiwe/communities';
+    return this.api.getResult<{
+      communities: CommunityDto[];
+      grouped: LifecycleSummaryDto[];
+      total: number;
+    }>(path);
+  }
+
   listStores() {
     return this.api.getResult<{ stores: StoreDto[] }>('/qiwe/stores');
   }
 
+  syncOrg() {
+    return this.api.postResult<{ departments: number; members: number; source: string; errors: string[] }>(
+      '/qiwe/org/sync',
+      {},
+    );
+  }
+
+  getOrgTree() {
+    return this.api.getResult<{ tree: unknown[] }>('/qiwe/org/tree');
+  }
+
   getGroup(roomId: string) {
     return this.api.getResult<{ group: GroupChatDto }>(`/qiwe/groups/${encodeURIComponent(roomId)}`);
   }
@@ -93,10 +146,14 @@ export class QiweApiService {
       storeName: dto.storeName || '—',
       ownerId: dto.ownerId,
       ownerName: dto.ownerName || '—',
-      ownerRole: 'single_group',
+      ownerRole: '',
       createdAt: new Date(dto.updatedAt),
       healthScore: dto.healthScore,
       healthStatus: dto.healthStatus as Group['healthStatus'],
+      healthGrade: dto.healthGrade || 'C',
+      opsActivityScore: dto.opsActivityScore ?? 0,
+      customerActivityScore: dto.customerActivityScore ?? 0,
+      lifecyclePhase: dto.lifecyclePhase || 'pre_handover',
       hasDocument: dto.hasDocument,
       documentPinned: dto.documentPinned,
       documentInNotice: dto.documentInNotice,

+ 63 - 18
src/app/core/services/mock-data.service.ts

@@ -4,7 +4,8 @@ import type {
   User, Store, Community, Group, GroupMember, GroupMessage,
   Document, RiskEvent, RiskKeyword, WorkOrder, ContentMaterial,
   WeeklyPlan, PlanItem, IntentLead, KOC, AcquisitionChannel,
-  ShowroomTarget, SalesData, LiveData, AppNotification, ComplianceResult
+  ShowroomTarget, SalesData, LiveData, AppNotification, ComplianceResult,
+  LifecyclePhase, HealthGrade,
 } from '../models';
 
 @Injectable({ providedIn: 'root' })
@@ -94,11 +95,9 @@ export class MockDataService {
   private generateUsers(): void {
     const users: User[] = [
       { id: 'u1', name: '张总监', email: 'admin@lami.com', phone: '13800000001', role: 'director', storeId: 's1', storeName: '上海总部', department: '总部', position: '总监', createdAt: new Date('2025-01-01') },
-      { id: 'u2', name: '周督导', email: 'dudao@lami.com', phone: '13800000002', role: 'regional_supervisor', storeId: 's1', storeName: '上海总部', department: '督导部', position: '区域督导', createdAt: new Date('2025-01-10') },
+      { id: 'u2', name: '周督导', email: 'dudao@lami.com', phone: '13800000002', role: 'regional_supervisor', storeId: 's1', storeName: '上海总部', regionCode: '华东', department: '督导部', position: '区域督导', createdAt: new Date('2025-01-10') },
       { id: 'u3', name: '李店长', email: 'lidian@lami.com', phone: '13800000003', role: 'store_manager', storeId: 's1', storeName: '上海总部', department: '直营店', position: '店长', createdAt: new Date('2025-01-15') },
-      { id: 'u4', name: '王单群', email: 'wangying@lami.com', phone: '13800000004', role: 'single_group', storeId: 's1', storeName: '上海总部', department: '运营部', position: '单群', createdAt: new Date('2025-02-01') },
       { id: 'u6', name: '刘店长', email: 'liudian2@lami.com', phone: '13800000006', role: 'store_manager', storeId: 's2', storeName: '北京旗舰店', department: '直营店', position: '店长', createdAt: new Date('2025-03-15') },
-      { id: 'u7', name: '陈单群', email: 'chenying@lami.com', phone: '13800000007', role: 'single_group', storeId: 's2', storeName: '北京旗舰店', department: '运营部', position: '单群', createdAt: new Date('2025-04-01') },
     ];
     (this as any).users_cache = users;
   }
@@ -112,21 +111,45 @@ export class MockDataService {
     (this as any).stores_cache = stores;
   }
 
+  private mockLifecyclePhase(deliveryDate: string): LifecyclePhase {
+    const delivery = new Date(`${deliveryDate.length === 7 ? deliveryDate + '-01' : deliveryDate}T00:00:00`);
+    const today = new Date();
+    today.setHours(0, 0, 0, 0);
+    const daysUntil = Math.round((delivery.getTime() - today.getTime()) / 86400000);
+    if (daysUntil > 90) return 'pre_handover';
+    if (daysUntil >= 0) return 'near_handover';
+    const daysSince = Math.abs(daysUntil);
+    if (daysSince <= 180) return 'near_handover';
+    if (daysSince <= 730) return 'post_handover';
+    return 'legacy';
+  }
+
+  private mockHealthGrade(score: number): HealthGrade {
+    if (score >= 85) return 'S';
+    if (score >= 70) return 'A';
+    if (score >= 55) return 'B';
+    return 'C';
+  }
+
   private generateCommunities(): void {
-    const communities: Community[] = [
-      { id: 'c1', name: '翠湖天地', address: '上海市黄浦区济南路260号', totalHouseholds: 1200, avgPrice: 168000, deliveryDate: '2024-06', storeId: 's1', storeName: '上海总部', coverageRate: 0.32, groupCount: 5, reachRate: 0.28 },
-      { id: 'c2', name: '汤臣一品', address: '上海市浦东新区花园石桥路28号', totalHouseholds: 800, avgPrice: 250000, deliveryDate: '2023-12', storeId: 's1', storeName: '上海总部', coverageRate: 0.45, groupCount: 4, reachRate: 0.41 },
+    const raw = [
+      { id: 'c1', name: '翠湖天地', address: '上海市黄浦区济南路260号', totalHouseholds: 1200, avgPrice: 168000, deliveryDate: '2027-06', storeId: 's1', storeName: '上海总部', coverageRate: 0.32, groupCount: 5, reachRate: 0.28 },
+      { id: 'c2', name: '汤臣一品', address: '上海市浦东新区花园石桥路28号', totalHouseholds: 800, avgPrice: 250000, deliveryDate: '2025-12', storeId: 's1', storeName: '上海总部', coverageRate: 0.45, groupCount: 4, reachRate: 0.41 },
       { id: 'c3', name: '华侨城苏河湾', address: '上海市静安区北苏州路912号', totalHouseholds: 600, avgPrice: 135000, deliveryDate: '2024-03', storeId: 's1', storeName: '上海总部', coverageRate: 0.38, groupCount: 3, reachRate: 0.35 },
-      { id: 'c4', name: '万达瑞华公馆', address: '上海市徐汇区龙腾大道288号', totalHouseholds: 500, avgPrice: 185000, deliveryDate: '2024-09', storeId: 's1', storeName: '上海总部', coverageRate: 0.22, groupCount: 3, reachRate: 0.18 },
+      { id: 'c4', name: '万达瑞华公馆', address: '上海市徐汇区龙腾大道288号', totalHouseholds: 500, avgPrice: 185000, deliveryDate: '2026-08', storeId: 's1', storeName: '上海总部', coverageRate: 0.22, groupCount: 3, reachRate: 0.18 },
       { id: 'c5', name: '宝格丽公寓', address: '上海市静安区河南北路108号', totalHouseholds: 400, avgPrice: 220000, deliveryDate: '2024-01', storeId: 's1', storeName: '上海总部', coverageRate: 0.52, groupCount: 3, reachRate: 0.48 },
-      { id: 'c6', name: '北京壹号院', address: '北京市朝阳区农展馆南路8号', totalHouseholds: 900, avgPrice: 180000, deliveryDate: '2024-06', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.35, groupCount: 4, reachRate: 0.31 },
+      { id: 'c6', name: '北京壹号院', address: '北京市朝阳区农展馆南路8号', totalHouseholds: 900, avgPrice: 180000, deliveryDate: '2026-09', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.35, groupCount: 4, reachRate: 0.31 },
       { id: 'c7', name: '中粮瑞府', address: '北京市海淀区西三环中路', totalHouseholds: 700, avgPrice: 120000, deliveryDate: '2024-04', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.28, groupCount: 3, reachRate: 0.25 },
       { id: 'c8', name: '融创壹号院子', address: '北京市通州区新华北路', totalHouseholds: 550, avgPrice: 95000, deliveryDate: '2025-01', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.15, groupCount: 3, reachRate: 0.12 },
-      { id: 'c9', name: '万柳书院', address: '北京市海淀区万柳中路', totalHouseholds: 650, avgPrice: 155000, deliveryDate: '2024-08', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.3, groupCount: 4, reachRate: 0.26 },
-      { id: 'c10', name: '深圳湾一号', address: '深圳市南山区科苑南路', totalHouseholds: 1000, avgPrice: 200000, deliveryDate: '2024-05', storeId: 's3', storeName: '深圳体验店', coverageRate: 0.4, groupCount: 4, reachRate: 0.36 },
+      { id: 'c9', name: '万柳书院', address: '北京市海淀区万柳中路', totalHouseholds: 650, avgPrice: 155000, deliveryDate: '2022-01', storeId: 's2', storeName: '北京旗舰店', coverageRate: 0.3, groupCount: 4, reachRate: 0.26 },
+      { id: 'c10', name: '深圳湾一号', address: '深圳市南山区科苑南路', totalHouseholds: 1000, avgPrice: 200000, deliveryDate: '2025-06', storeId: 's3', storeName: '深圳体验店', coverageRate: 0.4, groupCount: 4, reachRate: 0.36 },
       { id: 'c11', name: '华润城润府', address: '深圳市南山区深南大道', totalHouseholds: 750, avgPrice: 135000, deliveryDate: '2024-11', storeId: 's3', storeName: '深圳体验店', coverageRate: 0.25, groupCount: 3, reachRate: 0.22 },
-      { id: 'c12', name: '保利天珺', address: '深圳市福田区彩田路', totalHouseholds: 600, avgPrice: 160000, deliveryDate: '2025-03', storeId: 's3', storeName: '深圳体验店', coverageRate: 0.1, groupCount: 3, reachRate: 0.08 },
+      { id: 'c12', name: '保利天珺', address: '深圳市福田区彩田路', totalHouseholds: 600, avgPrice: 160000, deliveryDate: '2020-03', storeId: 's3', storeName: '深圳体验店', coverageRate: 0.1, groupCount: 3, reachRate: 0.08 },
     ];
+    const communities: Community[] = raw.map((c) => ({
+      ...c,
+      lifecyclePhase: this.mockLifecyclePhase(c.deliveryDate),
+    }));
     (this as any).communities_cache = communities;
   }
 
@@ -147,20 +170,25 @@ export class MockDataService {
       c10: '深圳体验店', c11: '深圳体验店', c12: '深圳体验店'
     };
     const owners = [
-      { id: 'u4', name: '王单群', role: 'single_group' },
-      { id: 'u7', name: '陈单群', role: 'single_group' },
       { id: 'u3', name: '李店长', role: 'store_manager' },
+      { id: 'u6', name: '刘店长', role: 'store_manager' },
     ];
 
     const groups: Group[] = [];
     let gid = 1;
 
+    const communityLifecycle: Record<string, LifecyclePhase> = {};
+    for (const c of (this as any).communities_cache as Community[]) {
+      communityLifecycle[c.id] = c.lifecyclePhase;
+    }
+
     for (const cid of Object.keys(communityMap)) {
       const groupCount = 3 + Math.floor(Math.random() * 4);
       for (let i = 1; i <= groupCount; i++) {
         const owner = owners[Math.floor(Math.random() * owners.length)];
         const msgCount = Math.floor(Math.random() * 80) + 5;
         const healthScore = Math.floor(Math.random() * 40) + 55;
+        const healthGrade = this.mockHealthGrade(healthScore);
         const statuses: Array<Group['activityLevel']> = ['high', 'medium', 'low', 'inactive'];
         groups.push({
           id: `g${gid}`,
@@ -176,6 +204,10 @@ export class MockDataService {
           createdAt: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
           healthScore,
           healthStatus: healthScore >= 80 ? 'healthy' : healthScore >= 60 ? 'warning' : 'critical',
+          healthGrade,
+          opsActivityScore: Math.min(100, Math.floor(healthScore * 0.55 + Math.random() * 20)),
+          customerActivityScore: Math.min(100, Math.floor(healthScore * 0.45 + Math.random() * 25)),
+          lifecyclePhase: communityLifecycle[cid] || 'pre_handover',
           hasDocument: Math.random() > 0.2,
           documentPinned: Math.random() > 0.3,
           documentInNotice: Math.random() > 0.4,
@@ -539,8 +571,12 @@ export class MockDataService {
     return this.getUsers().find(u => u.id === id);
   }
 
+  getUserByPhone(phone: string): User | undefined {
+    return this.getUsers().find((u) => u.phone === phone.trim());
+  }
+
   getUserByEmail(email: string): User | undefined {
-    return this.getUsers().find(u => u.email === email);
+    return this.getUsers().find((u) => u.email === email);
   }
 
   // ==================== Auth & persistence helpers ====================
@@ -563,11 +599,11 @@ export class MockDataService {
 
   registerUser(data: {
     name: string;
-    email: string;
     phone: string;
     password: string;
-    role: User['role'];
+    email: string;
   }): boolean {
+    if (this.getUserByPhone(data.phone)) return false;
     if (this.getUserByEmail(data.email)) return false;
 
     const users = [...this.getUsers()];
@@ -577,7 +613,7 @@ export class MockDataService {
       name: data.name.trim(),
       email: data.email.trim(),
       phone: data.phone.trim(),
-      role: data.role,
+      role: 'store_manager',
       storeId: 's1',
       storeName: '上海总部',
       department: '运营部',
@@ -595,6 +631,15 @@ export class MockDataService {
     return true;
   }
 
+  resetPasswordByEmail(email: string, newPassword: string): boolean {
+    const user = this.getUserByEmail(email.trim());
+    if (!user) return false;
+    const passwords = this.storage.get<Record<string, string>>(this.passwordsKey) ?? {};
+    passwords[user.id] = newPassword;
+    this.storage.set(this.passwordsKey, passwords);
+    return true;
+  }
+
   updateUser(user: User): void {
     const users = this.getUsers().map((u) => (u.id === user.id ? { ...user } : u));
     (this as any).users_cache = users;

+ 62 - 12
src/app/features/auth/forgot-password/forgot-password.component.html

@@ -3,30 +3,30 @@
     <!-- Title -->
     <div class="text-center mb-8">
       <h1 class="text-xl font-semibold text-surface-900 dark:text-surface-900">找回密码</h1>
-      <p class="text-sm text-surface-500 dark:text-surface-500 mt-1">请输入您的注册邮箱</p>
+      <p class="text-sm text-surface-500 dark:text-surface-500 mt-1">请输入注册邮箱验证账号并设置新密码</p>
     </div>
 
     @if (success) {
       <!-- Success State -->
       <div class="text-center">
         <div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-positive-100 text-positive-600 mb-4">
-          <fa-icon [icon]="faPaperPlane" class="text-xl"></fa-icon>
+          <fa-icon [icon]="faCircleCheck" class="text-xl"></fa-icon>
         </div>
-        <p class="text-surface-900 dark:text-surface-900 font-medium mb-2">密码重置链接已发送到您的邮箱</p>
+        <p class="text-surface-900 dark:text-surface-900 font-medium mb-2">密码重置成功</p>
         <p class="text-sm text-surface-500 dark:text-surface-500 mb-6">
-          请登录 {{ email }} 查收邮件,按照指引重置密码
+          账号 {{ email }} 的密码已更新,请使用新密码登录
         </p>
-        <a
-          routerLink="/login"
-          class="inline-block w-full h-11 leading-11 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm transition-colors text-center focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
+        <button
+          type="button"
+          (click)="goToLogin()"
+          class="w-full h-11 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
         >
           返回登录
-        </a>
+        </button>
       </div>
     } @else {
       <!-- Forgot Password Form -->
       <form (ngSubmit)="onSubmit()" class="space-y-5">
-        <!-- Error Message -->
         @if (errorMessage) {
           <div class="bg-negative-50 text-negative-600 text-sm px-4 py-3 rounded-lg">
             {{ errorMessage }}
@@ -51,16 +51,66 @@
           </div>
         </div>
 
-        <!-- Submit Button -->
+        <!-- New Password -->
+        <div>
+          <label for="newPassword" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">新密码</label>
+          <div class="relative">
+            <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500">
+              <fa-icon [icon]="faLock" class="text-sm"></fa-icon>
+            </div>
+            <input
+              id="newPassword"
+              [type]="showPassword ? 'text' : 'password'"
+              name="newPassword"
+              [(ngModel)]="newPassword"
+              placeholder="至少6位密码"
+              class="w-full pl-10 pr-11 h-11 bg-white dark:bg-surface-100 border border-surface-300 dark:border-surface-300 rounded-lg text-sm text-surface-900 dark:text-surface-900 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-primary-600 transition-colors"
+            />
+            <button
+              type="button"
+              (click)="togglePasswordVisibility()"
+              class="absolute inset-y-0 right-0 pr-3 flex items-center text-surface-400 dark:text-surface-500 hover:text-surface-600 transition-colors"
+              tabindex="-1"
+            >
+              <fa-icon [icon]="showPassword ? faEyeSlash : faEye" class="text-sm"></fa-icon>
+            </button>
+          </div>
+        </div>
+
+        <!-- Confirm Password -->
+        <div>
+          <label for="confirmPassword" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">确认新密码</label>
+          <div class="relative">
+            <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500">
+              <fa-icon [icon]="faLock" class="text-sm"></fa-icon>
+            </div>
+            <input
+              id="confirmPassword"
+              [type]="showConfirmPassword ? 'text' : 'password'"
+              name="confirmPassword"
+              [(ngModel)]="confirmPassword"
+              placeholder="请再次输入新密码"
+              class="w-full pl-10 pr-11 h-11 bg-white dark:bg-surface-100 border border-surface-300 dark:border-surface-300 rounded-lg text-sm text-surface-900 dark:text-surface-900 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-primary-600 transition-colors"
+            />
+            <button
+              type="button"
+              (click)="toggleConfirmPasswordVisibility()"
+              class="absolute inset-y-0 right-0 pr-3 flex items-center text-surface-400 dark:text-surface-500 hover:text-surface-600 transition-colors"
+              tabindex="-1"
+            >
+              <fa-icon [icon]="showConfirmPassword ? faEyeSlash : faEye" class="text-sm"></fa-icon>
+            </button>
+          </div>
+        </div>
+
         <button
           type="submit"
           class="w-full h-11 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
         >
-          发送重置链接
+          重置密码
         </button>
       </form>
 
-      <!-- Bottom Link -->
       <div class="mt-6 text-center text-sm">
         <a
           routerLink="/login"

+ 49 - 5
src/app/features/auth/forgot-password/forgot-password.component.ts

@@ -1,8 +1,14 @@
 import { Component, inject } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { RouterModule } from '@angular/router';
+import { Router, RouterModule } from '@angular/router';
 import { FaIconComponent } from '@fortawesome/angular-fontawesome';
-import { faEnvelope, faPaperPlane } from '@fortawesome/free-solid-svg-icons';
+import {
+  faEnvelope,
+  faLock,
+  faEye,
+  faEyeSlash,
+  faCircleCheck,
+} from '@fortawesome/free-solid-svg-icons';
 import { AuthService } from '../../../core/auth/auth.service';
 
 @Component({
@@ -13,15 +19,31 @@ import { AuthService } from '../../../core/auth/auth.service';
 })
 export class ForgotPasswordComponent {
   private authService = inject(AuthService);
+  private router = inject(Router);
 
   protected readonly faEnvelope = faEnvelope;
-  protected readonly faPaperPlane = faPaperPlane;
+  protected readonly faLock = faLock;
+  protected readonly faEye = faEye;
+  protected readonly faEyeSlash = faEyeSlash;
+  protected readonly faCircleCheck = faCircleCheck;
 
   email = '';
+  newPassword = '';
+  confirmPassword = '';
+  showPassword = false;
+  showConfirmPassword = false;
   submitted = false;
   success = false;
   errorMessage = '';
 
+  protected togglePasswordVisibility(): void {
+    this.showPassword = !this.showPassword;
+  }
+
+  protected toggleConfirmPasswordVisibility(): void {
+    this.showConfirmPassword = !this.showConfirmPassword;
+  }
+
   protected async onSubmit(): Promise<void> {
     this.submitted = true;
     this.errorMessage = '';
@@ -31,16 +53,38 @@ export class ForgotPasswordComponent {
       return;
     }
 
-    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) {
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email.trim())) {
       this.errorMessage = '邮箱格式不正确';
       return;
     }
 
-    const result = await this.authService.resetPassword(this.email.trim());
+    if (!this.newPassword) {
+      this.errorMessage = '请输入新密码';
+      return;
+    }
+
+    if (this.newPassword.length < 6) {
+      this.errorMessage = '新密码长度不能少于6位';
+      return;
+    }
+
+    if (this.newPassword !== this.confirmPassword) {
+      this.errorMessage = '两次输入的密码不一致';
+      return;
+    }
+
+    const result = await this.authService.resetPasswordByEmail(
+      this.email.trim(),
+      this.newPassword,
+    );
     if (result.ok) {
       this.success = true;
     } else {
       this.errorMessage = result.error ?? '该邮箱未注册,请检查后重试';
     }
   }
+
+  protected goToLogin(): void {
+    this.router.navigate(['/login'], { queryParams: { reset: 'true' } });
+  }
 }

+ 12 - 47
src/app/features/auth/login/login.component.html

@@ -12,26 +12,27 @@
     <!-- Error Message -->
     @if (errorMessage) {
       <div class="flex items-center gap-2 bg-negative-50 text-negative-600 text-sm px-4 py-3 rounded-lg mb-6">
-        <fa-icon [icon]="faEnvelope" class="shrink-0 text-xs"></fa-icon>
+        <fa-icon [icon]="faPhone" class="shrink-0 text-xs"></fa-icon>
         <span>{{ errorMessage }}</span>
       </div>
     }
 
     <!-- Login Form -->
     <form (ngSubmit)="onSubmit()" class="space-y-5">
-      <!-- Email -->
+      <!-- Phone -->
       <div>
-        <label for="email" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">邮箱</label>
+        <label for="phone" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">手机号</label>
         <div class="relative">
           <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500">
-            <fa-icon [icon]="faEnvelope" class="text-sm"></fa-icon>
+            <fa-icon [icon]="faPhone" class="text-sm"></fa-icon>
           </div>
           <input
-            id="email"
-            type="email"
-            name="email"
-            [(ngModel)]="email"
-            placeholder="请输入邮箱地址"
+            id="phone"
+            type="tel"
+            name="phone"
+            [(ngModel)]="phone"
+            placeholder="请输入11位手机号"
+            maxlength="11"
             class="w-full pl-10 pr-4 h-11 bg-white dark:bg-surface-100 border border-surface-300 dark:border-surface-300 rounded-lg text-sm text-surface-900 dark:text-surface-900 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-primary-600 transition-colors"
           />
         </div>
@@ -63,42 +64,6 @@
         </div>
       </div>
 
-      <!-- Role Selector -->
-      <div>
-        <label class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-2">角色</label>
-        <div class="grid grid-cols-5 gap-2">
-          @for (r of roles; track r.value) {
-            <label
-              class="flex flex-col items-center justify-center gap-1 px-1 py-2 rounded-lg border text-center cursor-pointer transition-all"
-              [class.border-primary-600]="role === r.value"
-              [class.bg-primary-50]="role === r.value"
-              [class.border-surface-200]="role !== r.value"
-              [class.dark:border-surface-300]="role !== r.value"
-              [class.bg-white]="role !== r.value"
-              [class.dark:bg-surface-100]="role !== r.value"
-              [class.hover:border-surface-300]="role !== r.value"
-              [class.dark:hover:border-surface-400]="role !== r.value"
-            >
-              <input
-                type="radio"
-                name="role"
-                [value]="r.value"
-                [(ngModel)]="role"
-                class="sr-only"
-              />
-              <span
-                class="text-xs leading-tight text-center"
-                [class.text-primary-700]="role === r.value"
-                [class.text-surface-600]="role !== r.value"
-                [class.dark:text-surface-600]="role !== r.value"
-              >
-                {{ r.label }}
-              </span>
-            </label>
-          }
-        </div>
-      </div>
-
       @if (successMessage) {
         <div class="p-3 rounded-lg bg-positive-50 text-positive-700 text-sm">{{ successMessage }}</div>
       }
@@ -110,8 +75,8 @@
           <span class="text-xs font-medium text-surface-500 dark:text-surface-500">一键测试登录</span>
           <div class="flex-1 h-px bg-surface-200 dark:bg-surface-300"></div>
         </div>
-        <div class="grid grid-cols-5 gap-2">
-          @for (r of roles; track r.value) {
+        <div class="grid grid-cols-3 gap-2">
+          @for (r of quickLoginRoles; track r.value) {
             <button
               type="button"
               (click)="quickLogin(r.value)"

+ 23 - 19
src/app/features/auth/login/login.component.ts

@@ -5,7 +5,7 @@ import { Router, RouterModule } from '@angular/router';
 import { FaIconComponent } from '@fortawesome/angular-fontawesome';
 import {
   faHouse,
-  faEnvelope,
+  faPhone,
   faLock,
   faEye,
   faEyeSlash,
@@ -13,8 +13,8 @@ import {
 } from '@fortawesome/free-solid-svg-icons';
 import { ActivatedRoute } from '@angular/router';
 import { AuthService } from '../../../core/auth/auth.service';
-import type { UserRole } from '../../../core/models';
 import { ROLE_OPTIONS } from '../../../core/models/role.constants';
+import type { UserRole } from '../../../core/models';
 import { environment } from '../../../../environments/environment';
 
 @Component({
@@ -29,15 +29,14 @@ export class LoginComponent implements OnInit {
   private route = inject(ActivatedRoute);
 
   protected readonly faHouse = faHouse;
-  protected readonly faEnvelope = faEnvelope;
+  protected readonly faPhone = faPhone;
   protected readonly faLock = faLock;
   protected readonly faEye = faEye;
   protected readonly faEyeSlash = faEyeSlash;
   protected readonly faUserCheck = faUserCheck;
 
-  email = '';
+  phone = '';
   password = '';
-  role: UserRole = 'single_group';
   rememberMe = false;
   showPassword = false;
   errorMessage = '';
@@ -45,26 +44,27 @@ export class LoginComponent implements OnInit {
   submitted = false;
   readonly enableQuickLogin = environment.enableQuickLogin;
 
-  protected readonly roles = ROLE_OPTIONS;
+  protected readonly quickLoginRoles = ROLE_OPTIONS;
 
-  private readonly quickLoginEmails: Record<UserRole, string> = {
-    director: 'admin@lami.com',
-    regional_supervisor: 'dudao@lami.com',
-    store_manager: 'lidian@lami.com',
-    single_group: 'wangying@lami.com',
+  private readonly quickLoginPhones: Record<UserRole, string> = {
+    director: '13800000001',
+    regional_supervisor: '13800000002',
+    store_manager: '13800000003',
   };
 
   ngOnInit(): void {
     if (this.route.snapshot.queryParamMap.get('registered') === 'true') {
-      this.successMessage = '注册成功,请使用邮箱和密码登录';
+      this.successMessage = '注册成功,请使用手机号和密码登录';
+    }
+    if (this.route.snapshot.queryParamMap.get('reset') === 'true') {
+      this.successMessage = '密码已重置,请使用新密码登录';
     }
   }
 
   protected quickLogin(role: UserRole): void {
     if (!environment.enableQuickLogin) return;
-    this.email = this.quickLoginEmails[role];
+    this.phone = this.quickLoginPhones[role];
     this.password = MockDataService.DEFAULT_PASSWORD;
-    this.role = role;
     this.onSubmit();
   }
 
@@ -76,21 +76,25 @@ export class LoginComponent implements OnInit {
     this.submitted = true;
     this.errorMessage = '';
 
-    if (!this.email || !this.password) {
-      this.errorMessage = '请填写邮箱和密码';
+    if (!this.phone || !this.password) {
+      this.errorMessage = '请填写手机号和密码';
+      return;
+    }
+
+    if (!/^\d{11}$/.test(this.phone.trim())) {
+      this.errorMessage = '手机号必须为11位数字';
       return;
     }
 
     const result = await this.authService.login(
-      this.email,
+      this.phone.trim(),
       this.password,
-      this.role,
       this.rememberMe,
     );
     if (result.ok) {
       this.router.navigate(['/workspace']);
     } else {
-      this.errorMessage = result.error ?? '邮箱或密码错误,请重试';
+      this.errorMessage = result.error ?? '手机号或密码错误,请重试';
     }
   }
 }

+ 24 - 60
src/app/features/auth/register/register.component.html

@@ -39,30 +39,6 @@
         }
       </div>
 
-      <!-- Email -->
-      <div>
-        <label for="email" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">邮箱</label>
-        <div class="relative">
-          <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500">
-            <fa-icon [icon]="faEnvelope" class="text-sm"></fa-icon>
-          </div>
-          <input
-            id="email"
-            type="email"
-            name="email"
-            [(ngModel)]="email"
-            placeholder="请输入邮箱地址"
-            class="w-full pl-10 pr-4 h-11 bg-white dark:bg-surface-100 border rounded-lg text-sm text-surface-900 dark:text-surface-900 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-primary-600 transition-colors"
-            [class.border-surface-300]="!errors.email"
-            [class.dark:border-surface-300]="!errors.email"
-            [class.border-negative-500]="errors.email"
-          />
-        </div>
-        @if (errors.email) {
-          <p class="mt-1 text-xs text-negative-600">{{ errors.email }}</p>
-        }
-      </div>
-
       <!-- Phone -->
       <div>
         <label for="phone" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">手机号</label>
@@ -88,6 +64,30 @@
         }
       </div>
 
+      <!-- Email -->
+      <div>
+        <label for="email" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">邮箱</label>
+        <div class="relative">
+          <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500">
+            <fa-icon [icon]="faEnvelope" class="text-sm"></fa-icon>
+          </div>
+          <input
+            id="email"
+            type="email"
+            name="email"
+            [(ngModel)]="email"
+            placeholder="请输入邮箱地址"
+            class="w-full pl-10 pr-4 h-11 bg-white dark:bg-surface-100 border rounded-lg text-sm text-surface-900 dark:text-surface-900 placeholder-surface-400 dark:placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-primary-600 transition-colors"
+            [class.border-surface-300]="!errors.email"
+            [class.dark:border-surface-300]="!errors.email"
+            [class.border-negative-500]="errors.email"
+          />
+        </div>
+        @if (errors.email) {
+          <p class="mt-1 text-xs text-negative-600">{{ errors.email }}</p>
+        }
+      </div>
+
       <!-- Password -->
       <div>
         <label for="password" class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-1">密码</label>
@@ -152,42 +152,6 @@
         }
       </div>
 
-      <!-- Role Selector -->
-      <div>
-        <label class="block text-sm font-medium text-surface-700 dark:text-surface-700 mb-2">角色</label>
-        <div class="grid grid-cols-5 gap-2">
-          @for (r of roles; track r.value) {
-            <label
-              class="flex flex-col items-center justify-center gap-1 px-1 py-2 rounded-lg border text-center cursor-pointer transition-all"
-              [class.border-primary-600]="role === r.value"
-              [class.bg-primary-50]="role === r.value"
-              [class.border-surface-200]="role !== r.value"
-              [class.dark:border-surface-300]="role !== r.value"
-              [class.bg-white]="role !== r.value"
-              [class.dark:bg-surface-100]="role !== r.value"
-              [class.hover:border-surface-300]="role !== r.value"
-              [class.dark:hover:border-surface-400]="role !== r.value"
-            >
-              <input
-                type="radio"
-                name="role"
-                [value]="r.value"
-                [(ngModel)]="role"
-                class="sr-only"
-              />
-              <span
-                class="text-xs leading-tight text-center"
-                [class.text-primary-700]="role === r.value"
-                [class.text-surface-600]="role !== r.value"
-                [class.dark:text-surface-600]="role !== r.value"
-              >
-                {{ r.label }}
-              </span>
-            </label>
-          }
-        </div>
-      </div>
-
       <!-- Register Button -->
       <button
         type="submit"

+ 11 - 17
src/app/features/auth/register/register.component.ts

@@ -11,8 +11,6 @@ import {
   faEyeSlash,
 } from '@fortawesome/free-solid-svg-icons';
 import { AuthService } from '../../../core/auth/auth.service';
-import type { UserRole } from '../../../core/models';
-import { ROLE_OPTIONS } from '../../../core/models/role.constants';
 
 interface FieldErrors {
   name?: string;
@@ -44,15 +42,12 @@ export class RegisterComponent {
   phone = '';
   password = '';
   confirmPassword = '';
-  role: UserRole = 'single_group';
   showPassword = false;
   showConfirmPassword = false;
   submitted = false;
   errors: FieldErrors = {};
   registerError = '';
 
-  protected readonly roles = ROLE_OPTIONS;
-
   protected togglePasswordVisibility(): void {
     this.showPassword = !this.showPassword;
   }
@@ -70,19 +65,19 @@ export class RegisterComponent {
       valid = false;
     }
 
-    if (!this.email.trim()) {
-      this.errors.email = '请输入邮箱';
+    if (!this.phone.trim()) {
+      this.errors.phone = '请输入手机号';
       valid = false;
-    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) {
-      this.errors.email = '邮箱格式不正确';
+    } else if (!/^\d{11}$/.test(this.phone.trim())) {
+      this.errors.phone = '手机号必须为11位数字';
       valid = false;
     }
 
-    if (!this.phone.trim()) {
-      this.errors.phone = '请输入手机号';
+    if (!this.email.trim()) {
+      this.errors.email = '请输入邮箱';
       valid = false;
-    } else if (!/^\d{11}$/.test(this.phone)) {
-      this.errors.phone = '手机号必须为11位数字';
+    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email.trim())) {
+      this.errors.email = '邮箱格式不正确';
       valid = false;
     }
 
@@ -115,17 +110,16 @@ export class RegisterComponent {
 
     const result = await this.authService.register(
       this.name,
-      this.email,
-      this.phone,
+      this.phone.trim(),
       this.password,
-      this.role,
+      this.email.trim(),
     );
     if (result.ok) {
       this.router.navigate(['/login'], {
         queryParams: { registered: 'true' },
       });
     } else {
-      this.registerError = result.error ?? '注册失败,该邮箱可能已被注册';
+      this.registerError = result.error ?? '注册失败,该手机号或邮箱可能已被注册';
     }
   }
 }

+ 67 - 52
src/app/features/dashboard/dashboard-scope.util.ts

@@ -1,76 +1,91 @@
-import type { Group, User, UserRole } from '../../core/models';
-import { ROLE_LABELS, USER_ROLES } from '../../core/models/role.constants';
-import type { ScopeCascaderOption, ScopeSelection } from '../../shared/components/scope-cascader/scope-cascader.component';
-import type { ScopeUserDto } from '../../core/services/api/dashboard-api.service';
+import type { Group, User } from '../../core/models';
+import { getRoleLabel } from '../../core/models/role.constants';
+import type { StoreScopeSelection } from '../../shared/components/store-scope-picker/store-scope-picker.component';
+import type { StoreDto } from '../../core/services/api/qiwe-api.service';
 
-export function buildScopeOptionsFromApi(users: ScopeUserDto[]): ScopeCascaderOption[] {
-  return USER_ROLES.map((role) => ({
-    value: role,
-    label: ROLE_LABELS[role],
-    children: users
-      .filter((u) => u.role === role)
-      .map((u) => ({
-        value: u.id,
-        label: role === 'store_manager' || role === 'single_group'
-          ? (u.storeName ? `${u.name}(${u.storeName})` : u.name)
-          : u.name,
-      })),
-  }));
+export function buildDefaultStoreScope(stores: StoreDto[], fallbackName = '门店'): StoreScopeSelection {
+  const first = stores[0];
+  if (first) {
+    return { level: 'store', storeId: first.id, regionCode: first.region, label: first.name };
+  }
+  return { level: 'global', label: '全部门店汇总(一级)' };
 }
 
-export function buildScopeOptions(users: User[]): ScopeCascaderOption[] {
-  return buildScopeOptionsFromApi(users.map((u) => ({
-    id: u.id,
-    name: u.name,
-    email: u.email,
-    phone: u.phone,
-    role: u.role,
-    storeId: u.storeId,
-    storeName: u.storeName,
-    department: u.department,
-    position: u.position,
-    createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : String(u.createdAt),
-  })));
+export function buildSupervisorDefaultScope(user: User, stores: StoreDto[]): StoreScopeSelection {
+  const regionStores = stores.filter((s) => s.region === user.regionCode);
+  const first = regionStores[0];
+  if (first) {
+    return {
+      level: 'store',
+      storeId: first.id,
+      regionCode: first.region,
+      label: first.name,
+    };
+  }
+  return {
+    level: 'region',
+    regionCode: user.regionCode,
+    label: `${user.regionCode || '区域'}汇总(一级)`,
+  };
 }
 
-export function getDefaultScope(user: User | null): ScopeSelection {
-  if (!user) {
-    return { role: 'director', label: '总监(全部)' };
+export function buildLockedStoreScope(user: User | null): StoreScopeSelection {
+  if (!user?.storeId) {
+    return { level: 'global', label: '未绑定门店' };
   }
-
-  const roleLabel = ROLE_LABELS[user.role];
   return {
-    role: user.role,
-    entityId: user.id,
-    label: `${roleLabel} / ${user.name}`,
+    level: 'store',
+    storeId: user.storeId,
+    regionCode: user.regionCode,
+    label: user.storeName || fallbackLabel(user),
   };
 }
 
-export function scopeToQuery(scope: ScopeSelection): { role?: UserRole; entityId?: string } {
+function fallbackLabel(user: User): string {
+  return `${getRoleLabel(user.role)} · ${user.name}`;
+}
+
+export function storeScopeToQuery(scope: StoreScopeSelection): {
+  scopeLevel: 'global' | 'region' | 'store';
+  storeId?: string;
+} {
   return {
-    role: scope.role,
-    entityId: scope.entityId,
+    scopeLevel: scope.level,
+    storeId: scope.level === 'store' ? scope.storeId : undefined,
   };
 }
 
-export function filterGroupsByScope(allGroups: Group[], scope: ScopeSelection, users: User[]): Group[] {
-  const { role, entityId } = scope;
-
-  if (!entityId) {
-    return allGroups;
+export function filterGroupsByStoreScope(allGroups: Group[], scope: StoreScopeSelection): Group[] {
+  if (scope.level === 'global') return allGroups;
+  if (scope.level === 'region' && scope.regionCode) {
+    return allGroups.filter((g) => g.storeName && matchRegionByStoreName(g, scope.regionCode!));
   }
+  if (!scope.storeId) return allGroups;
+  return allGroups.filter((g) => g.storeId === scope.storeId);
+}
+
+function matchRegionByStoreName(group: Group, regionCode: string): boolean {
+  const regionStoreMap: Record<string, string[]> = {
+    '华东': ['上海总部'],
+    '华北': ['北京旗舰店'],
+    '华南': ['深圳体验店'],
+  };
+  const names = regionStoreMap[regionCode] || [];
+  return names.includes(group.storeName);
+}
 
-  const entity = users.find((u) => u.id === entityId);
-  if (!entity) return allGroups;
+export function filterGroupsForUser(allGroups: Group[], user: User | null): Group[] {
+  if (!user) return allGroups;
 
-  switch (role) {
+  switch (user.role) {
     case 'director':
       return allGroups;
     case 'regional_supervisor':
+      return user.regionCode
+        ? allGroups.filter((g) => matchRegionByStoreName(g, user.regionCode!))
+        : [];
     case 'store_manager':
-      return entity.storeId ? allGroups.filter((g) => g.storeId === entity.storeId) : [];
-    case 'single_group':
-      return allGroups.filter((g) => g.ownerId === entityId);
+      return user.storeId ? allGroups.filter((g) => g.storeId === user.storeId) : [];
     default:
       return allGroups;
   }

+ 19 - 6
src/app/features/dashboard/dashboard.component.html

@@ -3,12 +3,15 @@
   [subtitle]="dashboardSubtitle()"
   [icon]="faChartPie"
 >
-  @if (showScopeCascader) {
-    <app-scope-cascader
+  @if (showStoreScopePicker) {
+    <app-store-scope-picker
       headerActions
-      [options]="scopeOptions"
-      [selection]="scopeSelection"
-      (selectionChange)="onScopeChange($event)"
+      [stores]="storeScopeOptions"
+      [selection]="storeScopeSelection"
+      [showGlobalOption]="showGlobalScopeOption"
+      [showRegionOption]="showRegionScopeOption"
+      [regionLabel]="regionScopeLabel"
+      (selectionChange)="onStoreScopeChange($event)"
     />
   }
 </app-page-header>
@@ -102,7 +105,7 @@
 </div>
 
 <!-- Charts Row -->
-<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
+<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
   <app-chart-card
     title="群活跃度分布"
     [chartType]="'doughnut'"
@@ -113,6 +116,16 @@
     [icon]="faChartPie"
     (cardClick)="navigateTo('/reports/monthly')"
   />
+  <app-chart-card
+    title="生命周期分布"
+    [chartType]="'bar'"
+    [chartData]="lifecycleChartData"
+    [loading]="loading"
+    [empty]="!loading && lifecycleChartData.labels?.length === 0"
+    [height]="'300px'"
+    [icon]="faChartBar"
+    (cardClick)="navigateTo('/communities')"
+  />
   <app-chart-card
     title="门店群数量对比"
     [chartType]="'bar'"

+ 109 - 35
src/app/features/dashboard/dashboard.component.ts

@@ -11,26 +11,27 @@ import { StatCardComponent } from '../../shared/components/stat-card/stat-card.c
 import { ChartCardComponent } from '../../shared/components/chart-card/chart-card.component';
 import { DataTableComponent } from '../../shared/components/data-table/data-table.component';
 import {
-  ScopeCascaderComponent,
-  type ScopeCascaderOption,
-  type ScopeSelection,
-} from '../../shared/components/scope-cascader/scope-cascader.component';
+  StoreScopePickerComponent,
+  type StoreScopeSelection,
+} from '../../shared/components/store-scope-picker/store-scope-picker.component';
 import { AuthStore } from '../../core/auth/auth.store';
 import { MockDataService } from '../../core/services/mock-data.service';
 import { DashboardApiService } from '../../core/services/api/dashboard-api.service';
 import { QiweApiService } from '../../core/services/api/qiwe-api.service';
 import { environment } from '../../../environments/environment';
-import { isDirector } from '../../core/models/role.constants';
+import { isDirector, isRegionalSupervisor } from '../../core/models/role.constants';
+import { LIFECYCLE_LABELS } from '../../core/models';
 import type { Group, RiskEvent } from '../../core/models';
 import type { ChartData } from 'chart.js';
 import {
-  buildScopeOptions,
-  buildScopeOptionsFromApi,
+  buildDefaultStoreScope,
+  buildSupervisorDefaultScope,
+  buildLockedStoreScope,
   computeDashboardStats,
-  filterGroupsByScope,
+  filterGroupsByStoreScope,
+  filterGroupsForUser,
   filterRiskEventsByGroups,
-  getDefaultScope,
-  scopeToQuery,
+  storeScopeToQuery,
 } from './dashboard-scope.util';
 
 @Component({
@@ -39,7 +40,7 @@ import {
   imports: [
     FaIconComponent,
     PageHeaderComponent,
-    ScopeCascaderComponent,
+    StoreScopePickerComponent,
     StatCardComponent,
     ChartCardComponent,
     DataTableComponent,
@@ -66,11 +67,15 @@ export class DashboardComponent implements OnInit {
   protected readonly faChartBar = faChartBar;
 
   loading = true;
-  showScopeCascader = false;
+  showStoreScopePicker = false;
+  showGlobalScopeOption = true;
+  showRegionScopeOption = false;
+  regionScopeLabel = '区域汇总(一级)';
+  scopeSubtitle = '';
   dataSource = signal<'api' | 'mock'>('mock');
 
-  scopeOptions: ScopeCascaderOption[] = [];
-  scopeSelection: ScopeSelection = { role: 'director', label: '总监(全部)' };
+  storeScopeOptions: Array<{ id: string; name: string }> = [];
+  storeScopeSelection: StoreScopeSelection = { level: 'store', label: '加载中…' };
 
   stats = {
     totalGroups: 0,
@@ -85,6 +90,7 @@ export class DashboardComponent implements OnInit {
   };
 
   activityChartData: ChartData<'doughnut'> = { labels: [], datasets: [] };
+  lifecycleChartData: ChartData<'bar'> = { labels: [], datasets: [] };
   storeChartData: ChartData<'bar'> = { labels: [], datasets: [] };
 
   recentRiskEvents: RiskEvent[] = [];
@@ -102,42 +108,44 @@ export class DashboardComponent implements OnInit {
   readonly topGroupColumns = [
     { key: 'name', label: '群名称', sortable: true },
     { key: 'communityName', label: '所属小区' },
+    { key: 'healthGrade', label: '评级', template: 'status' as const },
     { key: 'messageCountToday', label: '今日消息数', sortable: true },
-    { key: 'healthScore', label: '健康分', sortable: true },
     { key: 'actions', label: '操作', template: 'action' as const, width: '120px' }
   ];
 
   protected readonly dashboardSubtitle = () =>
     this.dataSource() === 'api'
-      ? '数据来自 Parse 数据库'
-      : '数据来自本地演示';
+      ? `数据来自 Parse · ${this.scopeSubtitle}`
+      : `本地演示 · ${this.scopeSubtitle}`;
 
   ngOnInit(): void {
     const user = this.authStore.user();
-    if (user?.role === 'single_group') {
-      this.router.navigate(['/groups']);
-      return;
-    }
 
-    this.showScopeCascader = isDirector(user?.role);
-    if (this.showScopeCascader) {
-      this.scopeSelection = { role: 'director', label: '总监(全部)' };
+    this.showStoreScopePicker = isDirector(user?.role) || isRegionalSupervisor(user?.role);
+    this.showGlobalScopeOption = isDirector(user?.role);
+    this.showRegionScopeOption = isRegionalSupervisor(user?.role);
+    this.regionScopeLabel = user?.regionCode ? `${user.regionCode}区域汇总(一级)` : '区域汇总(一级)';
+
+    if (this.showStoreScopePicker) {
+      this.storeScopeSelection = { level: 'store', label: '加载门店…' };
     } else {
-      this.scopeSelection = getDefaultScope(user);
+      this.storeScopeSelection = buildLockedStoreScope(user);
+      this.scopeSubtitle = this.storeScopeSelection.label;
     }
     void this.loadData();
   }
 
-  protected onScopeChange(scope: ScopeSelection): void {
-    this.scopeSelection = scope;
+  protected onStoreScopeChange(scope: StoreScopeSelection): void {
+    this.storeScopeSelection = scope;
     void this.loadData();
   }
 
   private async loadData(): Promise<void> {
     this.loading = true;
+    const user = this.authStore.user();
 
     if (environment.useBackendApi) {
-      const result = await this.dashboardApi.getOverview(scopeToQuery(this.scopeSelection));
+      const result = await this.dashboardApi.getOverview(storeScopeToQuery(this.storeScopeSelection));
       if (result.ok && result.data) {
         const data = result.data;
         this.dataSource.set('api');
@@ -145,10 +153,38 @@ export class DashboardComponent implements OnInit {
         this.topGroups = data.topGroups.map((g) => this.qiweApi.mapToGroup(g));
         this.recentRiskEvents = [];
 
-        if (this.showScopeCascader) {
-          this.scopeOptions = buildScopeOptionsFromApi(data.scopeUsers);
+        if (this.showStoreScopePicker && data.stores?.length) {
+          const accessible = isRegionalSupervisor(user?.role)
+            ? data.stores.filter((s) => s.region === user?.regionCode)
+            : data.stores;
+          this.storeScopeOptions = accessible.map((s) => ({ id: s.id, name: s.name, region: s.region }));
+          if (data.scope) {
+            this.storeScopeSelection = {
+              level: data.scope.level as StoreScopeSelection['level'],
+              storeId: data.scope.storeId,
+              regionCode: data.scope.regionCode,
+              label:
+                data.scope.level === 'global'
+                  ? '全部门店汇总(一级)'
+                  : data.scope.level === 'region'
+                    ? this.regionScopeLabel
+                    : (data.scope.storeName || '门店'),
+            };
+          }
         }
 
+        this.lifecycleChartData = {
+          labels: (data.lifecycleDistribution || []).map((x) => x.label),
+          datasets: [{
+            label: '客户群数',
+            data: (data.lifecycleDistribution || []).map((x) => x.count),
+            backgroundColor: ['#0070F2', '#E76500', '#188918', '#8D8D90'],
+            borderRadius: 4,
+          }],
+        };
+
+        this.scopeSubtitle = this.storeScopeSelection.label;
+
         this.activityChartData = {
           labels: ['高活跃', '中活跃', '低活跃', '不活跃'],
           datasets: [{
@@ -184,14 +220,37 @@ export class DashboardComponent implements OnInit {
 
   private loadMockData(): void {
     this.dataSource.set('mock');
+    const user = this.authStore.user();
 
-    const users = this.mockData.getUsers();
-    if (this.showScopeCascader) {
-      this.scopeOptions = buildScopeOptions(users);
+    const mockStores = [
+      { id: 's1', name: '上海总部', region: '华东' },
+      { id: 's2', name: '北京旗舰店', region: '华北' },
+      { id: 's3', name: '深圳体验店', region: '华南' },
+    ];
+
+    if (this.showStoreScopePicker) {
+      const accessible = isRegionalSupervisor(user?.role)
+        ? mockStores.filter((s) => s.region === user?.regionCode)
+        : mockStores;
+      this.storeScopeOptions = accessible;
+      if (this.storeScopeSelection.level !== 'global'
+        && this.storeScopeSelection.level !== 'region'
+        && !this.storeScopeSelection.storeId) {
+        this.storeScopeSelection = isRegionalSupervisor(user?.role)
+          ? buildSupervisorDefaultScope(user!, accessible.map((s) => ({ id: s.id, code: '', name: s.name, region: s.region })))
+          : buildDefaultStoreScope(accessible.map((s) => ({ id: s.id, code: '', name: s.name, region: s.region })));
+      }
+    } else {
+      this.storeScopeSelection = buildLockedStoreScope(user);
     }
 
+    this.scopeSubtitle = this.storeScopeSelection.label;
+
     const allGroups = this.mockData.getGroups();
-    const groups = filterGroupsByScope(allGroups, this.scopeSelection, users);
+    const roleScoped = filterGroupsForUser(allGroups, user);
+    const groups = this.showStoreScopePicker
+      ? filterGroupsByStoreScope(roleScoped, this.storeScopeSelection)
+      : roleScoped;
     const groupNames = new Set(groups.map((g) => g.name));
 
     this.stats = computeDashboardStats(
@@ -225,8 +284,23 @@ export class DashboardComponent implements OnInit {
       }],
     };
 
+    const lifecycleMap = new Map<string, number>();
+    for (const p of ['pre_handover', 'near_handover', 'post_handover', 'legacy']) {
+      lifecycleMap.set(p, groups.filter((g) => g.lifecyclePhase === p).length);
+    }
+    this.lifecycleChartData = {
+      labels: ['交房前', '临交房', '已交房', '老小区'],
+      datasets: [{
+        label: '客户群数',
+        data: Array.from(lifecycleMap.values()),
+        backgroundColor: ['#0070F2', '#E76500', '#188918', '#8D8D90'],
+        borderRadius: 4,
+      }],
+    };
+
+    const comparisonSource = this.storeScopeSelection.level === 'global' ? roleScoped : groups;
     const storeMap = new Map<string, number>();
-    groups.forEach(g => {
+    comparisonSource.forEach(g => {
       if (!g.storeName || g.storeName === '—') return;
       storeMap.set(g.storeName, (storeMap.get(g.storeName) || 0) + 1);
     });

+ 35 - 11
src/app/features/group-management/community-list/community-list.component.html

@@ -1,12 +1,36 @@
-<div class="space-y-6">
-  <app-page-header title="小区档案" subtitle="管理所有服务小区" [icon]="['fas','building']" />
+<app-page-header
+  title="小区档案"
+  [subtitle]="dataSource() === 'api' ? '按生命周期分组 · 数据来自 Parse' : '按生命周期分组 · 本地演示'"
+  [icon]="['fas','building']"
+/>
 
-  <app-data-table
-    [data]="communities"
-    [columns]="columns"
-    [loading]="loading"
-    [pageSize]="10"
-    [showSearch]="true"
-    (rowClick)="onRowClick($event)"
-  />
-</div>
+@if (grouped.length) {
+  <div class="flex flex-wrap gap-2 mb-4">
+    @for (tab of lifecycleTabs; track tab.value) {
+      <button
+        type="button"
+        (click)="selectPhase(tab.value)"
+        class="px-3 py-1.5 rounded-full text-sm border transition-colors"
+        [class.bg-primary-500]="activePhase() === tab.value"
+        [class.text-white]="activePhase() === tab.value"
+        [class.border-primary-500]="activePhase() === tab.value"
+        [class.border-surface-200]="activePhase() !== tab.value"
+      >
+        {{ tab.label }}
+        @if (tab.value !== 'all') {
+          <span class="ml-1 opacity-80">({{ phaseCount(tab.value) }})</span>
+        } @else {
+          <span class="ml-1 opacity-80">({{ communities.length }})</span>
+        }
+      </button>
+    }
+  </div>
+}
+
+<app-data-table
+  [data]="communities"
+  [columns]="columns"
+  [loading]="loading"
+  [pageSize]="10"
+  (rowClick)="onRowClick($event)"
+/>

+ 66 - 6
src/app/features/group-management/community-list/community-list.component.ts

@@ -1,7 +1,9 @@
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, OnInit, inject, signal } from '@angular/core';
 import { Router, RouterModule } from '@angular/router';
 import { MockDataService } from '../../../core/services/mock-data.service';
-import type { Community } from '../../../core/models';
+import { QiweApiService } from '../../../core/services/api/qiwe-api.service';
+import { environment } from '../../../../environments/environment';
+import { LIFECYCLE_LABELS, LIFECYCLE_PHASES, type Community, type LifecyclePhase } from '../../../core/models';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { DataTableComponent } from '../../../shared/components/data-table/data-table.component';
 
@@ -13,30 +15,88 @@ import { DataTableComponent } from '../../../shared/components/data-table/data-t
 })
 export class CommunityListComponent implements OnInit {
   private mockData = inject(MockDataService);
+  private qiweApi = inject(QiweApiService);
   private router = inject(Router);
 
   loading = true;
+  dataSource = signal<'api' | 'mock'>('mock');
+  activePhase = signal<LifecyclePhase | 'all'>('all');
   communities: Community[] = [];
+  grouped: Array<{ phase: LifecyclePhase; label: string; count: number }> = [];
+
+  readonly lifecycleTabs = [
+    { value: 'all' as const, label: '全部' },
+    ...LIFECYCLE_PHASES.map((p) => ({ value: p, label: LIFECYCLE_LABELS[p] })),
+  ];
 
   readonly columns = [
     { key: 'name', label: '小区名称', sortable: true },
+    { key: 'lifecyclePhase', label: '生命周期', template: 'status' as const },
     { key: 'address', label: '地址' },
     { key: 'totalHouseholds', label: '总户数', sortable: true },
     { key: 'avgPrice', label: '均价', sortable: true },
     { key: 'deliveryDate', label: '交付日期', sortable: true },
     { key: 'storeName', label: '门店' },
-    { key: 'coverageRate', label: '覆盖率', sortable: true },
-    { key: 'reachRate', label: '触达率', sortable: true },
     { key: 'groupCount', label: '群数量', sortable: true },
   ];
 
   ngOnInit(): void {
+    void this.loadData();
+  }
+
+  protected selectPhase(phase: LifecyclePhase | 'all'): void {
+    this.activePhase.set(phase);
+    void this.loadData();
+  }
+
+  protected phaseCount(phase: LifecyclePhase): number {
+    return this.grouped.find((g) => g.phase === phase)?.count ?? 0;
+  }
+
+  private async loadData(): Promise<void> {
     this.loading = true;
-    this.communities = this.mockData.getCommunities();
+    const phase = this.activePhase();
+
+    if (environment.useBackendApi) {
+      const result = await this.qiweApi.listCommunities(phase === 'all' ? undefined : phase);
+      if (result.ok && result.data) {
+        this.dataSource.set('api');
+        this.communities = result.data.communities.map((c) => ({
+          id: c.id,
+          name: c.name,
+          address: c.address,
+          totalHouseholds: c.totalHouseholds,
+          avgPrice: c.avgPrice,
+          deliveryDate: c.deliveryDate,
+          lifecyclePhase: c.lifecyclePhase,
+          storeId: c.storeId,
+          storeName: c.storeName,
+          coverageRate: 0,
+          groupCount: c.groupCount,
+          reachRate: 0,
+        }));
+        this.grouped = result.data.grouped.map((g) => ({
+          phase: g.phase,
+          label: g.label,
+          count: g.count,
+        }));
+        this.loading = false;
+        return;
+      }
+    }
+
+    this.dataSource.set('mock');
+    const all = this.mockData.getCommunities();
+    this.grouped = LIFECYCLE_PHASES.map((p) => ({
+      phase: p,
+      label: LIFECYCLE_LABELS[p],
+      count: all.filter((c) => c.lifecyclePhase === p).length,
+    }));
+    this.communities = phase === 'all' ? all : all.filter((c) => c.lifecyclePhase === phase);
     this.loading = false;
   }
 
-  onRowClick(community: Community): void {
+  onRowClick(_community: Community): void {
     this.router.navigate(['/groups']);
   }
 }

+ 21 - 4
src/app/features/group-management/group-list/group-list.component.ts

@@ -1,9 +1,11 @@
 import { Component, OnInit, inject, signal } from '@angular/core';
 import { ActivatedRoute, Router, RouterModule } from '@angular/router';
+import { AuthStore } from '../../../core/auth/auth.store';
 import { MockDataService } from '../../../core/services/mock-data.service';
+import { filterGroupsForUser } from '../../dashboard/dashboard-scope.util';
 import { QiweApiService, type StoreDto } from '../../../core/services/api/qiwe-api.service';
 import { environment } from '../../../../environments/environment';
-import type { Group } from '../../../core/models';
+import { LIFECYCLE_LABELS, LIFECYCLE_PHASES, HEALTH_GRADES, HEALTH_GRADE_LABELS, type Group } from '../../../core/models';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { StatCardComponent } from '../../../shared/components/stat-card/stat-card.component';
 import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
@@ -25,6 +27,7 @@ import { MessageStripComponent } from '../../../shared/components/message-strip/
   templateUrl: './group-list.component.html',
 })
 export class GroupListComponent implements OnInit {
+  private authStore = inject(AuthStore);
   private mockData = inject(MockDataService);
   private qiweApi = inject(QiweApiService);
   private router = inject(Router);
@@ -47,6 +50,8 @@ export class GroupListComponent implements OnInit {
       { label: '低', value: 'low' },
       { label: '不活跃', value: 'inactive' },
     ]},
+    { key: 'healthGrade', label: '健康评级', type: 'select', value: '', options: HEALTH_GRADES.map(g => ({ label: HEALTH_GRADE_LABELS[g], value: g })) },
+    { key: 'lifecyclePhase', label: '生命周期', type: 'select', value: '', options: LIFECYCLE_PHASES.map(p => ({ label: LIFECYCLE_LABELS[p], value: p })) },
     { key: 'healthStatus', label: '健康状态', type: 'select', value: '', options: [
       { label: '健康', value: 'healthy' },
       { label: '警告', value: 'warning' },
@@ -69,8 +74,11 @@ export class GroupListComponent implements OnInit {
     { key: 'memberCount', label: '成员数', sortable: true },
     { key: 'messageCountToday', label: '今日消息', sortable: true },
     { key: 'activityLevel', label: '活跃度', template: 'status' as const },
-    { key: 'healthScore', label: '健康分', sortable: true },
-    { key: 'healthStatus', label: '健康状态', template: 'status' as const },
+    { key: 'lifecyclePhase', label: '生命周期', template: 'status' as const },
+    { key: 'healthGrade', label: '评级', template: 'status' as const },
+    { key: 'opsActivityScore', label: '运营活跃', sortable: true },
+    { key: 'customerActivityScore', label: '客户活跃', sortable: true },
+    { key: 'healthScore', label: '综合分', sortable: true },
     { key: 'ownerName', label: '负责人' },
   ];
 
@@ -92,10 +100,15 @@ export class GroupListComponent implements OnInit {
     const healthStatus = this.filters.find(f => f.key === 'healthStatus')?.value;
     const hasDocument = this.filters.find(f => f.key === 'hasDocument')?.value;
 
+    const healthGrade = this.filters.find(f => f.key === 'healthGrade')?.value;
+    const lifecyclePhase = this.filters.find(f => f.key === 'lifecyclePhase')?.value;
+
     return {
       storeId: storeId || undefined,
       activityLevel: activityLevel || undefined,
       healthStatus: healthStatus || undefined,
+      healthGrade: healthGrade || undefined,
+      lifecyclePhase: lifecyclePhase || undefined,
       hasDocument: hasDocument || undefined,
       q: this.searchQuery || undefined,
     };
@@ -131,7 +144,7 @@ export class GroupListComponent implements OnInit {
       });
     }
 
-    this.groups = this.mockData.getGroups();
+    this.groups = filterGroupsForUser(this.mockData.getGroups(), this.authStore.user());
     this.applyStoreOptions([
       { id: 's1', code: 'SH001', name: '上海总部', region: '华东' },
       { id: 's2', code: 'BJ001', name: '北京旗舰店', region: '华北' },
@@ -180,11 +193,15 @@ export class GroupListComponent implements OnInit {
     const storeId = this.filters.find(f => f.key === 'storeId')?.value;
     const activityLevel = this.filters.find(f => f.key === 'activityLevel')?.value;
     const healthStatus = this.filters.find(f => f.key === 'healthStatus')?.value;
+    const healthGrade = this.filters.find(f => f.key === 'healthGrade')?.value;
+    const lifecyclePhase = this.filters.find(f => f.key === 'lifecyclePhase')?.value;
     const hasDoc = this.filters.find(f => f.key === 'hasDocument')?.value;
 
     if (storeId) result = result.filter(g => g.storeId === storeId);
     if (activityLevel) result = result.filter(g => g.activityLevel === activityLevel);
     if (healthStatus) result = result.filter(g => g.healthStatus === healthStatus);
+    if (healthGrade) result = result.filter(g => g.healthGrade === healthGrade);
+    if (lifecyclePhase) result = result.filter(g => g.lifecyclePhase === lifecyclePhase);
     if (hasDoc) result = result.filter(g => String(g.hasDocument) === hasDoc);
 
     if (this.searchQuery) {

+ 2 - 24
src/app/features/workspace/workspace.component.ts

@@ -98,19 +98,13 @@ export class WorkspaceComponent implements OnInit {
         return [
           { title: '区域概览', description: '所辖区域关键指标', items: ['区域群总数', '合规达标率', '活跃群占比', '待处理工单'] },
           { title: '今日待办', description: '督导待处理事项', items: ['门店合规复查', '风险事件跟进', '周报汇总'] },
-          { title: '门店动态', description: '各门店工作进展', items: ['店长执行情况', '群运营数据', '新增预警'] },
+          { title: '门店动态', description: '各门店工作进展', items: ['店长执行情况', '群运营数据', '新增预警'] },
         ];
       case 'store_manager':
         return [
           { title: '门店数据', description: '门店运营关键指标', items: ['门店客户群总数', '群成员总数', '活跃群占比', '合规达标率'] },
           { title: '今日待办', description: '店长待处理事项', items: ['审核运营计划', '检查文档合规', '处理风险事件', '跟进意向客户'] },
-          { title: '团队动态', description: '门店成员工作动态', items: ['单群执行情况', '文档更新进度', '线索跟进'] },
-        ];
-      case 'single_group':
-        return [
-          { title: '我的客户群', description: '负责群的关键指标', items: ['群成员数', '今日消息', '文档更新状态', '待处理预警'] },
-          { title: '今日运营', description: '今日需执行的任务', items: ['内容发布', '群互动', '文档维护'] },
-          { title: '预警事件', description: '本群相关预警', items: ['风险关键词命中', '待确认异常', '整改通知'] },
+          { title: '团队动态', description: '门店成员工作动态', items: ['群执行情况', '文档更新进度', '线索跟进'] },
         ];
       default:
         return [];
@@ -141,13 +135,6 @@ export class WorkspaceComponent implements OnInit {
           { label: '合规管理', icon: faCircleCheck, route: '/compliance', description: '检查文档合规情况' },
           { label: '总览看板', icon: faChartPie, route: '/dashboard', description: '查看整体数据' },
         ];
-      case 'single_group':
-        return [
-          { label: '我的客户群', icon: faUsers, route: '/groups', description: '查看负责的客户群' },
-          { label: '沟通记录', icon: faFilePen, route: '/documents', description: '维护沟通记录文档' },
-          { label: '预警事件', icon: faBell, route: '/workspace/issues', description: '查看待处理预警' },
-          { label: '通知', icon: faBell, route: '/workspace/notifications', description: '查看整改通知' },
-        ];
       default:
         return [];
     }
@@ -175,12 +162,6 @@ export class WorkspaceComponent implements OnInit {
           { title: `未读通知 (${this.unreadCount()})`, icon: faBell, route: '/workspace/notifications' },
           { title: '门店数据概览', icon: faStore, route: '/data-entry/stores' },
         ];
-      case 'single_group':
-        return [
-          { title: '文档待更新', icon: faFilePen, route: '/documents' },
-          { title: `未读通知 (${this.unreadCount()})`, icon: faBell, route: '/workspace/notifications' },
-          { title: '我的客户群', icon: faUsers, route: '/groups' },
-        ];
       default:
         return [];
     }
@@ -227,9 +208,6 @@ export class WorkspaceComponent implements OnInit {
       case 'store_manager':
         this.todaySummary = `${dateStr} · 门店运营平稳,${this.pendingWorkOrders().length}个待处理工单,${this.unreadCount()}条未读通知`;
         break;
-      case 'single_group':
-        this.todaySummary = `${dateStr} · 请关注客户群运营与文档维护,${this.unreadCount()}条通知`;
-        break;
       default:
         this.todaySummary = `${dateStr} · 欢迎使用拉迷企微客户服务系统`;
     }

+ 8 - 0
src/app/shared/components/status-badge/status-badge.component.ts

@@ -11,6 +11,14 @@ export class StatusBadgeComponent {
 
   get statusConfig(): { label: string; classes: string } {
     const map: Record<string, { label: string; classes: string }> = {
+      S: { label: 'S 级', classes: 'bg-positive-50 text-positive-700 border-positive-200' },
+      A: { label: 'A 级', classes: 'bg-primary-50 text-primary-700 border-primary-200' },
+      B: { label: 'B 级', classes: 'bg-critical-50 text-critical-700 border-critical-200' },
+      C: { label: 'C 级', classes: 'bg-negative-50 text-negative-700 border-negative-200' },
+      pre_handover: { label: '交房前', classes: 'bg-info-50 text-info-700 border-info-200' },
+      near_handover: { label: '临交房', classes: 'bg-critical-50 text-critical-700 border-critical-200' },
+      post_handover: { label: '已交房', classes: 'bg-positive-50 text-positive-700 border-positive-200' },
+      legacy: { label: '老小区', classes: 'bg-surface-100 text-surface-600 border-surface-200' },
       compliant: { label: '已达标', classes: 'bg-positive-50 text-positive-700 border-positive-200' },
       needs_fix: { label: '待整改', classes: 'bg-critical-50 text-critical-700 border-critical-200' },
       missing: { label: '缺表', classes: 'bg-negative-50 text-negative-700 border-negative-200' },

+ 60 - 0
src/app/shared/components/store-scope-picker/store-scope-picker.component.html

@@ -0,0 +1,60 @@
+<div class="relative inline-block text-left">
+  <button
+    type="button"
+    (click)="togglePanel()"
+    class="inline-flex items-center gap-2 min-w-[220px] h-10 px-3 rounded-lg border border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100 text-sm text-surface-800 dark:text-surface-800 hover:border-primary-400 transition-colors"
+  >
+    <fa-icon [icon]="faStore" class="text-primary-500 shrink-0" />
+    <span class="flex-1 truncate text-left">{{ currentSelection().label }}</span>
+    <fa-icon [icon]="faChevronDown" size="xs" class="text-surface-400 shrink-0" />
+  </button>
+
+  @if (open()) {
+    <ul
+      class="absolute right-0 z-50 mt-2 w-56 max-h-72 overflow-y-auto py-1 rounded-lg border border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100 shadow-lg"
+    >
+      @if (showGlobalOption) {
+        <li>
+          <button
+            type="button"
+            (click)="selectGlobal()"
+            class="w-full px-3 py-2 text-sm text-left transition-colors"
+            [class.bg-primary-50]="isGlobalActive()"
+            [class.text-primary-700]="isGlobalActive()"
+          >
+            全部门店汇总(一级)
+          </button>
+        </li>
+      }
+      @if (showRegionOption) {
+        <li>
+          <button
+            type="button"
+            (click)="selectRegion()"
+            class="w-full px-3 py-2 text-sm text-left transition-colors"
+            [class.bg-primary-50]="isRegionActive()"
+            [class.text-primary-700]="isRegionActive()"
+          >
+            {{ regionLabel }}
+          </button>
+        </li>
+      }
+      <li class="px-3 py-1.5 text-xs text-surface-400 border-t border-surface-100 dark:border-surface-300 mt-1">
+        按门店查看(二级)
+      </li>
+      @for (store of stores; track store.id) {
+        <li>
+          <button
+            type="button"
+            (click)="selectStore(store)"
+            class="w-full px-3 py-2 text-sm text-left transition-colors"
+            [class.bg-primary-50]="isStoreActive(store.id)"
+            [class.text-primary-700]="isStoreActive(store.id)"
+          >
+            {{ store.name }}
+          </button>
+        </li>
+      }
+    </ul>
+  }
+</div>

+ 108 - 0
src/app/shared/components/store-scope-picker/store-scope-picker.component.ts

@@ -0,0 +1,108 @@
+import {
+  Component,
+  ElementRef,
+  HostListener,
+  Input,
+  Output,
+  EventEmitter,
+  inject,
+  signal,
+} from '@angular/core';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { faChevronDown, faStore } from '@fortawesome/free-solid-svg-icons';
+
+export type StoreScopeLevel = 'global' | 'region' | 'store';
+
+export interface StoreScopeSelection {
+  level: StoreScopeLevel;
+  storeId?: string;
+  regionCode?: string;
+  label: string;
+}
+
+export interface StoreScopeOption {
+  id: string;
+  name: string;
+  region?: string;
+}
+
+@Component({
+  selector: 'app-store-scope-picker',
+  standalone: true,
+  imports: [FaIconComponent],
+  templateUrl: './store-scope-picker.component.html',
+})
+export class StoreScopePickerComponent {
+  private readonly elementRef = inject(ElementRef);
+
+  protected readonly faChevronDown = faChevronDown;
+  protected readonly faStore = faStore;
+
+  @Input() stores: StoreScopeOption[] = [];
+  @Input() showGlobalOption = true;
+  @Input() showRegionOption = false;
+  @Input() regionLabel = '区域汇总(一级)';
+  @Input() set selection(value: StoreScopeSelection | null) {
+    if (value) {
+      this.currentSelection.set(value);
+    }
+  }
+  @Output() selectionChange = new EventEmitter<StoreScopeSelection>();
+
+  protected readonly open = signal(false);
+  protected readonly currentSelection = signal<StoreScopeSelection>({
+    level: 'store',
+    label: '选择门店',
+  });
+
+  protected togglePanel(): void {
+    this.open.update((v) => !v);
+  }
+
+  protected selectGlobal(): void {
+    this.applySelection({ level: 'global', label: '全部门店汇总(一级)' });
+  }
+
+  protected selectRegion(): void {
+    this.applySelection({
+      level: 'region',
+      regionCode: this.stores[0]?.region,
+      label: this.regionLabel,
+    });
+  }
+
+  protected selectStore(store: StoreScopeOption): void {
+    this.applySelection({
+      level: 'store',
+      storeId: store.id,
+      regionCode: store.region,
+      label: store.name,
+    });
+  }
+
+  protected isGlobalActive(): boolean {
+    return this.currentSelection().level === 'global';
+  }
+
+  protected isRegionActive(): boolean {
+    return this.currentSelection().level === 'region';
+  }
+
+  protected isStoreActive(storeId: string): boolean {
+    const s = this.currentSelection();
+    return s.level === 'store' && s.storeId === storeId;
+  }
+
+  private applySelection(selection: StoreScopeSelection): void {
+    this.currentSelection.set(selection);
+    this.selectionChange.emit(selection);
+    this.open.set(false);
+  }
+
+  @HostListener('document:click', ['$event'])
+  protected onDocumentClick(event: MouseEvent): void {
+    if (!this.elementRef.nativeElement.contains(event.target)) {
+      this.open.set(false);
+    }
+  }
+}