dashboard.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import { CommonModule } from '@angular/common';
  2. import { RouterModule } from '@angular/router';
  3. import { Subscription } from 'rxjs';
  4. import { signal, Component, OnInit, AfterViewInit, OnDestroy, computed } from '@angular/core';
  5. import { AdminDashboardService } from './dashboard.service';
  6. import * as echarts from 'echarts';
  7. @Component({
  8. selector: 'app-admin-dashboard',
  9. standalone: true,
  10. imports: [CommonModule, RouterModule],
  11. templateUrl: './dashboard.html',
  12. styleUrl: './dashboard.scss'
  13. })
  14. export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
  15. // 统计数据
  16. stats = {
  17. totalProjects: signal(128),
  18. activeProjects: signal(86),
  19. completedProjects: signal(42),
  20. totalDesigners: signal(24),
  21. totalCustomers: signal(356),
  22. totalRevenue: signal(1258000)
  23. };
  24. // 图表周期切换
  25. projectPeriod = signal<'6m' | '12m'>('6m');
  26. revenuePeriod = signal<'quarter' | 'year'>('quarter');
  27. // 详情面板
  28. detailOpen = signal(false);
  29. detailType = signal<'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue' | null>(null);
  30. detailTitle = computed(() => {
  31. switch (this.detailType()) {
  32. case 'totalProjects': return '项目总览';
  33. case 'active': return '进行中项目详情';
  34. case 'completed': return '已完成项目详情';
  35. case 'designers': return '设计师统计详情';
  36. case 'customers': return '客户统计详情';
  37. case 'revenue': return '收入统计详情';
  38. default: return '';
  39. }
  40. });
  41. // 明细数据与筛选/分页状态
  42. detailData = signal<any[]>([]);
  43. keyword = signal('');
  44. statusFilter = signal('all');
  45. dateFrom = signal<string | null>(null);
  46. dateTo = signal<string | null>(null);
  47. pageIndex = signal(1);
  48. pageSize = signal(10);
  49. // 过滤后的数据
  50. filteredData = computed(() => {
  51. const type = this.detailType();
  52. let data = this.detailData();
  53. const kw = this.keyword().trim().toLowerCase();
  54. const status = this.statusFilter();
  55. const from = this.dateFrom() ? new Date(this.dateFrom() as string).getTime() : null;
  56. const to = this.dateTo() ? new Date(this.dateTo() as string).getTime() : null;
  57. // 关键词过滤(对常见字段做并集匹配)
  58. if (kw) {
  59. data = data.filter((it: any) => {
  60. const text = [it.name, it.projectName, it.customer, it.owner, it.status, it.level, it.invoiceNo]
  61. .filter(Boolean)
  62. .join(' ')
  63. .toLowerCase();
  64. return text.includes(kw);
  65. });
  66. }
  67. // 状态过滤(不同类型对应不同字段)
  68. if (status && status !== 'all') {
  69. data = data.filter((it: any) => {
  70. switch (type) {
  71. case 'active':
  72. case 'completed':
  73. case 'totalProjects':
  74. return (it.status || '').toLowerCase() === status.toLowerCase();
  75. case 'designers':
  76. return (it.level || '').toLowerCase() === status.toLowerCase();
  77. case 'customers':
  78. return (it.status || '').toLowerCase() === status.toLowerCase();
  79. case 'revenue':
  80. return (it.type || '').toLowerCase() === status.toLowerCase();
  81. default:
  82. return true;
  83. }
  84. });
  85. }
  86. // 时间范围过滤:尝试使用 date/endDate/startDate 三者之一
  87. if (from || to) {
  88. data = data.filter((it: any) => {
  89. const d = it.date || it.endDate || it.startDate;
  90. if (!d) return false;
  91. const t = new Date(d).getTime();
  92. if (from && t < from) return false;
  93. if (to && t > to) return false;
  94. return true;
  95. });
  96. }
  97. return data;
  98. });
  99. // 分页后的数据
  100. pagedData = computed(() => {
  101. const size = this.pageSize();
  102. const idx = this.pageIndex();
  103. const start = (idx - 1) * size;
  104. return this.filteredData().slice(start, start + size);
  105. });
  106. totalItems = computed(() => this.filteredData().length);
  107. totalPagesComputed = computed(() => Math.max(1, Math.ceil(this.totalItems() / this.pageSize())));
  108. private subscriptions: Subscription = new Subscription();
  109. private projectChart: any | null = null;
  110. private revenueChart: any | null = null;
  111. private detailChart: any | null = null;
  112. constructor(private dashboardService: AdminDashboardService) {}
  113. ngOnInit(): void {
  114. this.loadDashboardData();
  115. }
  116. ngAfterViewInit(): void {
  117. this.initCharts();
  118. window.addEventListener('resize', this.handleResize);
  119. }
  120. ngOnDestroy(): void {
  121. this.subscriptions.unsubscribe();
  122. window.removeEventListener('resize', this.handleResize);
  123. this.disposeCharts();
  124. }
  125. private disposeCharts(): void {
  126. if (this.projectChart) { this.projectChart.dispose(); this.projectChart = null; }
  127. if (this.revenueChart) { this.revenueChart.dispose(); this.revenueChart = null; }
  128. if (this.detailChart) { this.detailChart.dispose(); this.detailChart = null; }
  129. }
  130. loadDashboardData(): void {
  131. // 模拟调用服务
  132. this.subscriptions.add(
  133. this.dashboardService.getDashboardStats().subscribe(() => {
  134. // 使用默认模拟数据,必要时可在此更新 signals
  135. })
  136. );
  137. }
  138. // ====== 顶部两张主图表 ======
  139. initCharts(): void {
  140. this.initProjectChart();
  141. this.initRevenueChart();
  142. }
  143. private initProjectChart(): void {
  144. const el = document.getElementById('projectTrendChart');
  145. if (!el) return;
  146. this.projectChart?.dispose();
  147. this.projectChart = echarts.init(el);
  148. const { x, newProjects, completed } = this.prepareProjectSeries(this.projectPeriod());
  149. this.projectChart.setOption({
  150. title: { text: '项目数量趋势', left: 'center', textStyle: { fontSize: 16 } },
  151. tooltip: { trigger: 'axis' },
  152. legend: { data: ['新项目', '完成项目'] },
  153. xAxis: { type: 'category', data: x },
  154. yAxis: { type: 'value' },
  155. series: [
  156. { name: '新项目', type: 'line', data: newProjects, lineStyle: { color: '#165DFF' }, itemStyle: { color: '#165DFF' }, smooth: true },
  157. { name: '完成项目', type: 'line', data: completed, lineStyle: { color: '#00B42A' }, itemStyle: { color: '#00B42A' }, smooth: true }
  158. ]
  159. });
  160. }
  161. private initRevenueChart(): void {
  162. const el = document.getElementById('revenueChart');
  163. if (!el) return;
  164. this.revenueChart?.dispose();
  165. this.revenueChart = echarts.init(el);
  166. if (this.revenuePeriod() === 'quarter') {
  167. this.revenueChart.setOption({
  168. title: { text: '季度收入统计', left: 'center', textStyle: { fontSize: 16 } },
  169. tooltip: { trigger: 'item' },
  170. series: [{
  171. type: 'pie', radius: '65%',
  172. data: [
  173. { value: 350000, name: '第一季度' },
  174. { value: 420000, name: '第二季度' },
  175. { value: 488000, name: '第三季度' }
  176. ],
  177. emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } }
  178. }]
  179. });
  180. } else {
  181. // 全年:使用柱状图展示 12 个月收入
  182. const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
  183. const revenue = [120, 140, 160, 155, 180, 210, 230, 220, 240, 260, 280, 300].map(v => v * 1000);
  184. this.revenueChart.setOption({
  185. title: { text: '全年收入统计', left: 'center', textStyle: { fontSize: 16 } },
  186. tooltip: { trigger: 'axis' },
  187. xAxis: { type: 'category', data: months },
  188. yAxis: { type: 'value' },
  189. series: [{ type: 'bar', data: revenue, itemStyle: { color: '#165DFF' } }]
  190. });
  191. }
  192. }
  193. private prepareProjectSeries(period: '6m' | '12m') {
  194. if (period === '6m') {
  195. return {
  196. x: ['1月','2月','3月','4月','5月','6月'],
  197. newProjects: [18, 25, 32, 28, 42, 38],
  198. completed: [15, 20, 25, 22, 35, 30]
  199. };
  200. }
  201. // 12个月数据(构造平滑趋势)
  202. return {
  203. x: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
  204. newProjects: [12,18,22,26,30,34,36,38,40,42,44,46],
  205. completed: [10,14,18,20,24,28,30,31,33,35,37,39]
  206. };
  207. }
  208. setProjectPeriod(p: '6m' | '12m') {
  209. if (this.projectPeriod() !== p) {
  210. this.projectPeriod.set(p);
  211. this.initProjectChart();
  212. }
  213. }
  214. setRevenuePeriod(p: 'quarter' | 'year') {
  215. if (this.revenuePeriod() !== p) {
  216. this.revenuePeriod.set(p);
  217. this.initRevenueChart();
  218. }
  219. }
  220. // ====== 详情面板 ======
  221. showPanel(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
  222. this.detailType.set(type);
  223. // 重置筛选与分页
  224. this.keyword.set('');
  225. this.statusFilter.set('all');
  226. this.dateFrom.set(null);
  227. this.dateTo.set(null);
  228. this.pageIndex.set(1);
  229. // 加载本次类型的明细数据
  230. this.loadDetailData(type);
  231. // 打开抽屉并初始化图表
  232. this.detailOpen.set(true);
  233. setTimeout(() => this.initDetailChart(), 0);
  234. document.body.style.overflow = 'hidden';
  235. }
  236. closeDetailPanel() {
  237. this.detailOpen.set(false);
  238. this.detailType.set(null);
  239. this.detailChart?.dispose();
  240. this.detailChart = null;
  241. document.body.style.overflow = 'auto';
  242. }
  243. private initDetailChart() {
  244. const el = document.getElementById('detailChart');
  245. if (!el) return;
  246. this.detailChart?.dispose();
  247. this.detailChart = echarts.init(el);
  248. const type = this.detailType();
  249. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  250. const { x, newProjects, completed } = this.prepareProjectSeries('12m');
  251. this.detailChart.setOption({
  252. title: { text: '项目趋势详情(12个月)', left: 'center' },
  253. tooltip: { trigger: 'axis' },
  254. legend: { data: ['新项目','完成项目'] },
  255. xAxis: { type: 'category', data: x },
  256. yAxis: { type: 'value' },
  257. series: [
  258. { name: '新项目', type: 'line', data: newProjects, smooth: true, lineStyle: { color: '#165DFF' } },
  259. { name: '完成项目', type: 'line', data: completed, smooth: true, lineStyle: { color: '#00B42A' } }
  260. ]
  261. });
  262. return;
  263. }
  264. if (type === 'designers') {
  265. this.detailChart.setOption({
  266. title: { text: '设计师完成量对比', left: 'center' },
  267. tooltip: { trigger: 'axis' },
  268. legend: { data: ['完成','进行中'] },
  269. xAxis: { type: 'category', data: ['张','李','王','赵','陈'] },
  270. yAxis: { type: 'value' },
  271. series: [
  272. { name: '完成', type: 'bar', data: [18,15,12,10,9], itemStyle: { color: '#00B42A' } },
  273. { name: '进行中', type: 'bar', data: [8,6,5,4,3], itemStyle: { color: '#165DFF' } }
  274. ]
  275. });
  276. return;
  277. }
  278. if (type === 'customers') {
  279. this.detailChart.setOption({
  280. title: { text: '客户增长趋势', left: 'center' },
  281. tooltip: { trigger: 'axis' },
  282. xAxis: { type: 'category', data: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'] },
  283. yAxis: { type: 'value' },
  284. series: [{ name: '客户数', type: 'line', data: [280,300,310,320,330,340,345,350,355,360,368,380], itemStyle: { color: '#4E5BA6' }, smooth: true }]
  285. });
  286. return;
  287. }
  288. // revenue
  289. this.detailChart.setOption({
  290. title: { text: '收入构成(年度)', left: 'center' },
  291. tooltip: { trigger: 'item' },
  292. series: [{
  293. type: 'pie', radius: ['35%','65%'],
  294. data: [
  295. { value: 520000, name: '设计服务' },
  296. { value: 360000, name: '材料供应' },
  297. { value: 180000, name: '售后与增值' },
  298. { value: 198000, name: '其他' }
  299. ]
  300. }]
  301. });
  302. }
  303. private handleResize = (): void => {
  304. this.projectChart?.resize();
  305. this.revenueChart?.resize();
  306. this.detailChart?.resize();
  307. };
  308. formatCurrency(amount: number): string {
  309. return '¥' + amount.toLocaleString('zh-CN');
  310. }
  311. // 兼容旧模板调用(已调整为 showPanel)
  312. showProjectDetails(status: 'active' | 'completed'): void {
  313. this.showPanel(status);
  314. }
  315. showCustomersDetails(): void { this.showPanel('customers'); }
  316. showFinanceDetails(): void { this.showPanel('revenue'); }
  317. // ====== 明细数据:加载、列配置、导出与分页 ======
  318. private loadDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
  319. // 构造模拟数据(足量便于分页演示)
  320. const now = new Date();
  321. const addDays = (base: Date, days: number) => new Date(base.getTime() + days * 86400000);
  322. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  323. const status = type === 'active' ? '进行中' : (type === 'completed' ? '已完成' : undefined);
  324. const items = Array.from({ length: 42 }).map((_, i) => ({
  325. id: 'P' + String(1000 + i),
  326. name: `项目 ${i + 1}`,
  327. owner: ['张三','李四','王五','赵六'][i % 4],
  328. status: status || (i % 3 === 0 ? '进行中' : (i % 3 === 1 ? '已完成' : '待启动')),
  329. startDate: addDays(now, -60 + i).toISOString().slice(0,10),
  330. endDate: addDays(now, -30 + i).toISOString().slice(0,10)
  331. }));
  332. this.detailData.set(items);
  333. return;
  334. }
  335. if (type === 'designers') {
  336. const items = Array.from({ length: 36 }).map((_, i) => ({
  337. id: 'D' + String(200 + i),
  338. name: ['张一','李二','王三','赵四','陈五','刘六'][i % 6],
  339. level: ['junior','mid','senior'][i % 3],
  340. completed: 10 + (i % 15),
  341. inProgress: 1 + (i % 6),
  342. avgCycle: 7 + (i % 10),
  343. date: addDays(now, -i).toISOString().slice(0,10)
  344. }));
  345. this.detailData.set(items);
  346. return;
  347. }
  348. if (type === 'customers') {
  349. const items = Array.from({ length: 28 }).map((_, i) => ({
  350. id: 'C' + String(300 + i),
  351. name: ['王先生','李女士','赵先生','陈女士'][i % 4],
  352. projects: 1 + (i % 5),
  353. lastContact: addDays(now, -i * 2).toISOString().slice(0,10),
  354. status: ['潜在','跟进中','已签约'][i % 3],
  355. date: addDays(now, -i * 2).toISOString().slice(0,10)
  356. }));
  357. this.detailData.set(items);
  358. return;
  359. }
  360. // revenue
  361. const items = Array.from({ length: 34 }).map((_, i) => ({
  362. invoiceNo: 'INV-' + String(10000 + i),
  363. customer: ['华夏地产','远景家装','绿洲装饰','宏图设计'][i % 4],
  364. amount: 5000 + (i % 12) * 1500,
  365. type: ['service','material','addon'][i % 3],
  366. date: addDays(now, -i).toISOString().slice(0,10)
  367. }));
  368. this.detailData.set(items);
  369. }
  370. getColumns(): { label: string; field: string; formatter?: (v: any) => string }[] {
  371. const type = this.detailType();
  372. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  373. return [
  374. { label: '项目编号', field: 'id' },
  375. { label: '项目名称', field: 'name' },
  376. { label: '负责人', field: 'owner' },
  377. { label: '状态', field: 'status' },
  378. { label: '开始日期', field: 'startDate' },
  379. { label: '结束日期', field: 'endDate' }
  380. ];
  381. }
  382. if (type === 'designers') {
  383. return [
  384. { label: '设计师', field: 'name' },
  385. { label: '级别', field: 'level' },
  386. { label: '完成量', field: 'completed' },
  387. { label: '进行中', field: 'inProgress' },
  388. { label: '平均周期(天)', field: 'avgCycle' },
  389. { label: '统计日期', field: 'date' }
  390. ];
  391. }
  392. if (type === 'customers') {
  393. return [
  394. { label: '客户名', field: 'name' },
  395. { label: '项目数', field: 'projects' },
  396. { label: '最后联系', field: 'lastContact' },
  397. { label: '状态', field: 'status' }
  398. ];
  399. }
  400. // revenue
  401. return [
  402. { label: '发票号', field: 'invoiceNo' },
  403. { label: '客户', field: 'customer' },
  404. { label: '金额', field: 'amount', formatter: (v: any) => this.formatCurrency(Number(v)) },
  405. { label: '类型', field: 'type' },
  406. { label: '日期', field: 'date' }
  407. ];
  408. }
  409. // 状态选项(随类型变化)
  410. getStatusOptions(): { label: string; value: string }[] {
  411. const type = this.detailType();
  412. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  413. return [
  414. { label: '全部状态', value: 'all' },
  415. { label: '进行中', value: '进行中' },
  416. { label: '已完成', value: '已完成' },
  417. { label: '待启动', value: '待启动' }
  418. ];
  419. }
  420. if (type === 'designers') {
  421. return [
  422. { label: '全部级别', value: 'all' },
  423. { label: 'junior', value: 'junior' },
  424. { label: 'mid', value: 'mid' },
  425. { label: 'senior', value: 'senior' }
  426. ];
  427. }
  428. if (type === 'customers') {
  429. return [
  430. { label: '全部状态', value: 'all' },
  431. { label: '潜在', value: '潜在' },
  432. { label: '跟进中', value: '跟进中' },
  433. { label: '已签约', value: '已签约' }
  434. ];
  435. }
  436. return [
  437. { label: '全部类型', value: 'all' },
  438. { label: 'service', value: 'service' },
  439. { label: 'material', value: 'material' },
  440. { label: 'addon', value: 'addon' }
  441. ];
  442. }
  443. // 交互:筛选与分页
  444. setKeyword(v: string) { this.keyword.set(v); this.pageIndex.set(1); }
  445. setStatus(v: string) { this.statusFilter.set(v); this.pageIndex.set(1); }
  446. setDateFrom(v: string) { this.dateFrom.set(v || null); this.pageIndex.set(1); }
  447. setDateTo(v: string) { this.dateTo.set(v || null); this.pageIndex.set(1); }
  448. resetFilters() {
  449. this.keyword.set('');
  450. this.statusFilter.set('all');
  451. this.dateFrom.set(null);
  452. this.dateTo.set(null);
  453. this.pageIndex.set(1);
  454. }
  455. get totalPages() { return this.totalPagesComputed(); }
  456. goToPage(n: number) { const tp = this.totalPagesComputed(); if (n >= 1 && n <= tp) this.pageIndex.set(n); }
  457. prevPage() { this.goToPage(this.pageIndex() - 1); }
  458. nextPage() { this.goToPage(this.pageIndex() + 1); }
  459. // 生成页码列表(最多展示 5 个,居中当前页)
  460. getPages(): number[] {
  461. const total = this.totalPagesComputed();
  462. const current = this.pageIndex();
  463. const max = 5;
  464. let start = Math.max(1, current - Math.floor(max / 2));
  465. let end = Math.min(total, start + max - 1);
  466. start = Math.max(1, end - max + 1);
  467. const pages: number[] = [];
  468. for (let i = start; i <= end; i++) pages.push(i);
  469. return pages;
  470. }
  471. // 导出当前过滤结果为 CSV
  472. exportCSV() {
  473. const cols = this.getColumns();
  474. const rows = this.filteredData();
  475. const header = cols.map(c => c.label).join(',');
  476. const escape = (val: any) => {
  477. if (val === undefined || val === null) return '';
  478. const s = String(val).replace(/"/g, '""');
  479. return /[",\n]/.test(s) ? `"${s}"` : s;
  480. };
  481. const lines = rows.map(r => cols.map(c => escape(c.formatter ? c.formatter((r as any)[c.field]) : (r as any)[c.field])).join(','));
  482. const csv = [header, ...lines].join('\n');
  483. const blob = new Blob(["\ufeff" + csv], { type: 'text/csv;charset=utf-8;' });
  484. const url = URL.createObjectURL(blob);
  485. const a = document.createElement('a');
  486. a.href = url;
  487. const filenameMap: any = { totalProjects: '项目总览', active: '进行中项目', completed: '已完成项目', designers: '设计师统计', customers: '客户统计', revenue: '收入统计' };
  488. a.download = `${filenameMap[this.detailType() || 'totalProjects']}-明细.csv`;
  489. a.click();
  490. URL.revokeObjectURL(url);
  491. }
  492. }