dashboard.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  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 { FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
  7. import { WxworkAuth } from 'fmode-ng/core';
  8. import * as echarts from 'echarts';
  9. @Component({
  10. selector: 'app-admin-dashboard',
  11. standalone: true,
  12. imports: [CommonModule, RouterModule],
  13. templateUrl: './dashboard.html',
  14. styleUrl: './dashboard.scss'
  15. })
  16. export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
  17. // 统计数据
  18. stats = {
  19. totalProjects: signal(128),
  20. activeProjects: signal(86),
  21. completedProjects: signal(42),
  22. totalDesigners: signal(24),
  23. totalCustomers: signal(356),
  24. totalRevenue: signal(1258000)
  25. };
  26. // 图表周期切换
  27. projectPeriod = signal<'6m' | '12m'>('6m');
  28. revenuePeriod = signal<'quarter' | 'year'>('quarter');
  29. // 详情面板
  30. detailOpen = signal(false);
  31. detailType = signal<'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue' | null>(null);
  32. detailTitle = computed(() => {
  33. switch (this.detailType()) {
  34. case 'totalProjects': return '项目总览';
  35. case 'active': return '进行中项目详情';
  36. case 'completed': return '已完成项目详情';
  37. case 'designers': return '设计师统计详情';
  38. case 'customers': return '客户统计详情';
  39. case 'revenue': return '收入统计详情';
  40. default: return '';
  41. }
  42. });
  43. // 明细数据与筛选/分页状态
  44. detailData = signal<any[]>([]);
  45. keyword = signal('');
  46. statusFilter = signal('all');
  47. dateFrom = signal<string | null>(null);
  48. dateTo = signal<string | null>(null);
  49. pageIndex = signal(1);
  50. pageSize = signal(10);
  51. // 过滤后的数据
  52. filteredData = computed(() => {
  53. const type = this.detailType();
  54. let data = this.detailData();
  55. const kw = this.keyword().trim().toLowerCase();
  56. const status = this.statusFilter();
  57. const from = this.dateFrom() ? new Date(this.dateFrom() as string).getTime() : null;
  58. const to = this.dateTo() ? new Date(this.dateTo() as string).getTime() : null;
  59. // 关键词过滤(对常见字段做并集匹配)
  60. if (kw) {
  61. data = data.filter((it: any) => {
  62. const text = [it.name, it.projectName, it.customer, it.owner, it.status, it.level, it.invoiceNo]
  63. .filter(Boolean)
  64. .join(' ')
  65. .toLowerCase();
  66. return text.includes(kw);
  67. });
  68. }
  69. // 状态过滤(不同类型对应不同字段)
  70. if (status && status !== 'all') {
  71. data = data.filter((it: any) => {
  72. switch (type) {
  73. case 'active':
  74. case 'completed':
  75. case 'totalProjects':
  76. return (it.status || '').toLowerCase() === status.toLowerCase();
  77. case 'designers':
  78. return (it.level || '').toLowerCase() === status.toLowerCase();
  79. case 'customers':
  80. return (it.status || '').toLowerCase() === status.toLowerCase();
  81. case 'revenue':
  82. return (it.type || '').toLowerCase() === status.toLowerCase();
  83. default:
  84. return true;
  85. }
  86. });
  87. }
  88. // 时间范围过滤:尝试使用 date/endDate/startDate 三者之一
  89. if (from || to) {
  90. data = data.filter((it: any) => {
  91. const d = it.date || it.endDate || it.startDate;
  92. if (!d) return false;
  93. const t = new Date(d).getTime();
  94. if (from && t < from) return false;
  95. if (to && t > to) return false;
  96. return true;
  97. });
  98. }
  99. return data;
  100. });
  101. // 分页后的数据
  102. pagedData = computed(() => {
  103. const size = this.pageSize();
  104. const idx = this.pageIndex();
  105. const start = (idx - 1) * size;
  106. return this.filteredData().slice(start, start + size);
  107. });
  108. totalItems = computed(() => this.filteredData().length);
  109. totalPagesComputed = computed(() => Math.max(1, Math.ceil(this.totalItems() / this.pageSize())));
  110. private subscriptions: Subscription = new Subscription();
  111. private projectChart: any | null = null;
  112. private revenueChart: any | null = null;
  113. private detailChart: any | null = null;
  114. private wxAuth: WxworkAuth | null = null;
  115. private currentUser: FmodeUser | null = null;
  116. constructor(private dashboardService: AdminDashboardService) {
  117. this.initAuth();
  118. }
  119. async ngOnInit(): Promise<void> {
  120. await this.authenticateAndLoadData();
  121. }
  122. // 初始化企业微信认证
  123. private initAuth(): void {
  124. try {
  125. this.wxAuth = new WxworkAuth({
  126. cid: 'cDL6R1hgSi' // 公司帐套ID
  127. });
  128. console.log('✅ 管理员仪表板企业微信认证初始化成功');
  129. } catch (error) {
  130. console.error('❌ 管理员仪表板企业微信认证初始化失败:', error);
  131. }
  132. }
  133. // 认证并加载数据
  134. private async authenticateAndLoadData(): Promise<void> {
  135. try {
  136. // 执行企业微信认证和登录
  137. const { user } = await this.wxAuth!.authenticateAndLogin();
  138. this.currentUser = user;
  139. if (user) {
  140. console.log('✅ 管理员登录成功:', user.get('username'));
  141. this.loadDashboardData();
  142. } else {
  143. console.error('❌ 管理员登录失败');
  144. }
  145. } catch (error) {
  146. console.error('❌ 管理员认证过程出错:', error);
  147. }
  148. }
  149. ngAfterViewInit(): void {
  150. this.initCharts();
  151. window.addEventListener('resize', this.handleResize);
  152. }
  153. ngOnDestroy(): void {
  154. this.subscriptions.unsubscribe();
  155. window.removeEventListener('resize', this.handleResize);
  156. this.disposeCharts();
  157. }
  158. private disposeCharts(): void {
  159. if (this.projectChart) { this.projectChart.dispose(); this.projectChart = null; }
  160. if (this.revenueChart) { this.revenueChart.dispose(); this.revenueChart = null; }
  161. if (this.detailChart) { this.detailChart.dispose(); this.detailChart = null; }
  162. }
  163. async loadDashboardData(): Promise<void> {
  164. try {
  165. // 加载项目统计数据
  166. await this.loadProjectStats();
  167. // 加载用户统计数据
  168. await this.loadUserStats();
  169. // 加载收入统计数据
  170. await this.loadRevenueStats();
  171. console.log('✅ 管理员仪表板数据加载完成');
  172. } catch (error) {
  173. console.error('❌ 管理员仪表板数据加载失败:', error);
  174. // 降级到模拟数据
  175. this.loadMockData();
  176. }
  177. }
  178. // 加载项目统计数据
  179. private async loadProjectStats(): Promise<void> {
  180. try {
  181. const projectQuery = new FmodeQuery('Project');
  182. projectQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
  183. // 总项目数
  184. const totalProjects = await projectQuery.count();
  185. this.stats.totalProjects.set(totalProjects);
  186. // 进行中项目数
  187. projectQuery.equalTo('status', '进行中');
  188. const activeProjects = await projectQuery.count();
  189. this.stats.activeProjects.set(activeProjects);
  190. // 已完成项目数
  191. projectQuery.equalTo('status', '已完成');
  192. const completedProjects = await projectQuery.count();
  193. this.stats.completedProjects.set(completedProjects);
  194. console.log(`✅ 项目统计: 总计${totalProjects}, 进行中${activeProjects}, 已完成${completedProjects}`);
  195. } catch (error) {
  196. console.error('❌ 项目统计加载失败:', error);
  197. throw error;
  198. }
  199. }
  200. // 加载用户统计数据
  201. private async loadUserStats(): Promise<void> {
  202. try {
  203. // 设计师统计
  204. const designerQuery = new FmodeQuery('Profile');
  205. designerQuery.contains('roleName', '设计师');
  206. designerQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
  207. const designers = await designerQuery.count();
  208. this.stats.totalDesigners.set(designers);
  209. // 客户统计
  210. const customerQuery = new FmodeQuery('ContactInfo');
  211. customerQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
  212. const customers = await customerQuery.count();
  213. this.stats.totalCustomers.set(customers);
  214. console.log(`✅ 用户统计: 设计师${designers}, 客户${customers}`);
  215. } catch (error) {
  216. console.error('❌ 用户统计加载失败:', error);
  217. throw error;
  218. }
  219. }
  220. // 加载收入统计数据
  221. private async loadRevenueStats(): Promise<void> {
  222. try {
  223. // 从订单表计算总收入
  224. const orderQuery = new FmodeQuery('Order');
  225. orderQuery.equalTo('status', 'paid');
  226. const orders = await orderQuery.find();
  227. let totalRevenue = 0;
  228. for (const order of orders) {
  229. const amount = order.get('amount') || 0;
  230. totalRevenue += amount;
  231. }
  232. this.stats.totalRevenue.set(totalRevenue);
  233. console.log(`✅ 收入统计: 总收入 ¥${totalRevenue.toLocaleString()}`);
  234. } catch (error) {
  235. console.error('❌ 收入统计加载失败:', error);
  236. throw error;
  237. }
  238. }
  239. // 降级到模拟数据
  240. private loadMockData(): void {
  241. console.warn('⚠️ 使用模拟数据');
  242. this.subscriptions.add(
  243. this.dashboardService.getDashboardStats().subscribe(stats => {
  244. this.stats.totalProjects.set(stats.totalProjects);
  245. this.stats.activeProjects.set(stats.activeProjects);
  246. this.stats.completedProjects.set(stats.completedProjects);
  247. this.stats.totalDesigners.set(stats.totalDesigners);
  248. this.stats.totalCustomers.set(stats.totalCustomers);
  249. this.stats.totalRevenue.set(stats.totalRevenue);
  250. })
  251. );
  252. }
  253. // ====== 顶部两张主图表 ======
  254. initCharts(): void {
  255. this.initProjectChart();
  256. this.initRevenueChart();
  257. }
  258. private initProjectChart(): void {
  259. const el = document.getElementById('projectTrendChart');
  260. if (!el) return;
  261. this.projectChart?.dispose();
  262. this.projectChart = echarts.init(el);
  263. const { x, newProjects, completed } = this.prepareProjectSeries(this.projectPeriod());
  264. this.projectChart.setOption({
  265. title: { text: '项目数量趋势', left: 'center', textStyle: { fontSize: 16 } },
  266. tooltip: { trigger: 'axis' },
  267. legend: { data: ['新项目', '完成项目'] },
  268. xAxis: { type: 'category', data: x },
  269. yAxis: { type: 'value' },
  270. series: [
  271. { name: '新项目', type: 'line', data: newProjects, lineStyle: { color: '#165DFF' }, itemStyle: { color: '#165DFF' }, smooth: true },
  272. { name: '完成项目', type: 'line', data: completed, lineStyle: { color: '#00B42A' }, itemStyle: { color: '#00B42A' }, smooth: true }
  273. ]
  274. });
  275. }
  276. private initRevenueChart(): void {
  277. const el = document.getElementById('revenueChart');
  278. if (!el) return;
  279. this.revenueChart?.dispose();
  280. this.revenueChart = echarts.init(el);
  281. if (this.revenuePeriod() === 'quarter') {
  282. this.revenueChart.setOption({
  283. title: { text: '季度收入统计', left: 'center', textStyle: { fontSize: 16 } },
  284. tooltip: { trigger: 'item' },
  285. series: [{
  286. type: 'pie', radius: '65%',
  287. data: [
  288. { value: 350000, name: '第一季度' },
  289. { value: 420000, name: '第二季度' },
  290. { value: 488000, name: '第三季度' }
  291. ],
  292. emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } }
  293. }]
  294. });
  295. } else {
  296. // 全年:使用柱状图展示 12 个月收入
  297. const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
  298. const revenue = [120, 140, 160, 155, 180, 210, 230, 220, 240, 260, 280, 300].map(v => v * 1000);
  299. this.revenueChart.setOption({
  300. title: { text: '全年收入统计', left: 'center', textStyle: { fontSize: 16 } },
  301. tooltip: { trigger: 'axis' },
  302. xAxis: { type: 'category', data: months },
  303. yAxis: { type: 'value' },
  304. series: [{ type: 'bar', data: revenue, itemStyle: { color: '#165DFF' } }]
  305. });
  306. }
  307. }
  308. private prepareProjectSeries(period: '6m' | '12m') {
  309. if (period === '6m') {
  310. return {
  311. x: ['1月','2月','3月','4月','5月','6月'],
  312. newProjects: [18, 25, 32, 28, 42, 38],
  313. completed: [15, 20, 25, 22, 35, 30]
  314. };
  315. }
  316. // 12个月数据(构造平滑趋势)
  317. return {
  318. x: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
  319. newProjects: [12,18,22,26,30,34,36,38,40,42,44,46],
  320. completed: [10,14,18,20,24,28,30,31,33,35,37,39]
  321. };
  322. }
  323. setProjectPeriod(p: '6m' | '12m') {
  324. if (this.projectPeriod() !== p) {
  325. this.projectPeriod.set(p);
  326. this.initProjectChart();
  327. }
  328. }
  329. setRevenuePeriod(p: 'quarter' | 'year') {
  330. if (this.revenuePeriod() !== p) {
  331. this.revenuePeriod.set(p);
  332. this.initRevenueChart();
  333. }
  334. }
  335. // ====== 详情面板 ======
  336. async showPanel(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
  337. this.detailType.set(type);
  338. // 重置筛选与分页
  339. this.keyword.set('');
  340. this.statusFilter.set('all');
  341. this.dateFrom.set(null);
  342. this.dateTo.set(null);
  343. this.pageIndex.set(1);
  344. // 加载本次类型的明细数据
  345. await this.loadDetailData(type);
  346. // 打开抽屉并初始化图表
  347. this.detailOpen.set(true);
  348. setTimeout(() => this.initDetailChart(), 0);
  349. document.body.style.overflow = 'hidden';
  350. }
  351. closeDetailPanel() {
  352. this.detailOpen.set(false);
  353. this.detailType.set(null);
  354. this.detailChart?.dispose();
  355. this.detailChart = null;
  356. document.body.style.overflow = 'auto';
  357. }
  358. private initDetailChart() {
  359. const el = document.getElementById('detailChart');
  360. if (!el) return;
  361. this.detailChart?.dispose();
  362. this.detailChart = echarts.init(el);
  363. const type = this.detailType();
  364. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  365. const { x, newProjects, completed } = this.prepareProjectSeries('12m');
  366. this.detailChart.setOption({
  367. title: { text: '项目趋势详情(12个月)', left: 'center' },
  368. tooltip: { trigger: 'axis' },
  369. legend: { data: ['新项目','完成项目'] },
  370. xAxis: { type: 'category', data: x },
  371. yAxis: { type: 'value' },
  372. series: [
  373. { name: '新项目', type: 'line', data: newProjects, smooth: true, lineStyle: { color: '#165DFF' } },
  374. { name: '完成项目', type: 'line', data: completed, smooth: true, lineStyle: { color: '#00B42A' } }
  375. ]
  376. });
  377. return;
  378. }
  379. if (type === 'designers') {
  380. this.detailChart.setOption({
  381. title: { text: '设计师完成量对比', left: 'center' },
  382. tooltip: { trigger: 'axis' },
  383. legend: { data: ['完成','进行中'] },
  384. xAxis: { type: 'category', data: ['张','李','王','赵','陈'] },
  385. yAxis: { type: 'value' },
  386. series: [
  387. { name: '完成', type: 'bar', data: [18,15,12,10,9], itemStyle: { color: '#00B42A' } },
  388. { name: '进行中', type: 'bar', data: [8,6,5,4,3], itemStyle: { color: '#165DFF' } }
  389. ]
  390. });
  391. return;
  392. }
  393. if (type === 'customers') {
  394. this.detailChart.setOption({
  395. title: { text: '客户增长趋势', left: 'center' },
  396. tooltip: { trigger: 'axis' },
  397. xAxis: { type: 'category', data: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'] },
  398. yAxis: { type: 'value' },
  399. series: [{ name: '客户数', type: 'line', data: [280,300,310,320,330,340,345,350,355,360,368,380], itemStyle: { color: '#4E5BA6' }, smooth: true }]
  400. });
  401. return;
  402. }
  403. // revenue
  404. this.detailChart.setOption({
  405. title: { text: '收入构成(年度)', left: 'center' },
  406. tooltip: { trigger: 'item' },
  407. series: [{
  408. type: 'pie', radius: ['35%','65%'],
  409. data: [
  410. { value: 520000, name: '设计服务' },
  411. { value: 360000, name: '材料供应' },
  412. { value: 180000, name: '售后与增值' },
  413. { value: 198000, name: '其他' }
  414. ]
  415. }]
  416. });
  417. }
  418. private handleResize = (): void => {
  419. this.projectChart?.resize();
  420. this.revenueChart?.resize();
  421. this.detailChart?.resize();
  422. };
  423. formatCurrency(amount: number): string {
  424. return '¥' + amount.toLocaleString('zh-CN');
  425. }
  426. // 兼容旧模板调用(已调整为 showPanel)
  427. showProjectDetails(status: 'active' | 'completed'): void {
  428. this.showPanel(status);
  429. }
  430. showCustomersDetails(): void { this.showPanel('customers'); }
  431. showFinanceDetails(): void { this.showPanel('revenue'); }
  432. // ====== 明细数据:加载、列配置、导出与分页 ======
  433. private async loadDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
  434. try {
  435. switch (type) {
  436. case 'totalProjects':
  437. case 'active':
  438. case 'completed':
  439. await this.loadProjectDetailData(type);
  440. break;
  441. case 'designers':
  442. await this.loadDesignerDetailData();
  443. break;
  444. case 'customers':
  445. await this.loadCustomerDetailData();
  446. break;
  447. case 'revenue':
  448. await this.loadRevenueDetailData();
  449. break;
  450. }
  451. } catch (error) {
  452. console.error('❌ 详情数据加载失败:', error);
  453. this.loadMockDetailData(type);
  454. }
  455. }
  456. // 加载项目详情数据
  457. private async loadProjectDetailData(type: 'totalProjects' | 'active' | 'completed'): Promise<void> {
  458. const projectQuery = new FmodeQuery('Project');
  459. projectQuery.include("onwer")
  460. if (type === 'active') {
  461. projectQuery.equalTo('status', '进行中');
  462. } else if (type === 'completed') {
  463. projectQuery.equalTo('status', '已完成');
  464. }
  465. const projects = await projectQuery.descending('createdAt').find();
  466. const detailItems = projects.map((project: FmodeObject) => ({
  467. id: project.id,
  468. name: project.get('name') || '未命名项目',
  469. owner: project.get('owner')?.get('name') || '未分配',
  470. status: project.get('status') || '未知',
  471. startDate: project.get('startDate') ? new Date(project.get('startDate')).toISOString().slice(0,10) : '',
  472. endDate: project.get('endDate') ? new Date(project.get('endDate')).toISOString().slice(0,10) : '',
  473. date: project.get('createdAt') ? new Date(project.get('createdAt')).toISOString().slice(0,10) : ''
  474. }));
  475. this.detailData.set(detailItems);
  476. }
  477. // 加载设计师详情数据
  478. private async loadDesignerDetailData(): Promise<void> {
  479. const designerQuery = new FmodeQuery('Profile');
  480. designerQuery.equalTo('roleName', '组员');
  481. const designers = await designerQuery.descending('createdAt').find();
  482. const detailItems = designers.map((designer: FmodeObject) => ({
  483. id: designer.id,
  484. name: designer.get('name') || '未命名',
  485. level: designer.get('level') || 'junior',
  486. completed: designer.get('completedProjects') || 0,
  487. inProgress: designer.get('activeProjects') || 0,
  488. avgCycle: designer.get('avgCycle') || 7,
  489. date: designer.get('createdAt') ? new Date(designer.get('createdAt')).toISOString().slice(0,10) : ''
  490. }));
  491. this.detailData.set(detailItems);
  492. }
  493. // 加载客户详情数据
  494. private async loadCustomerDetailData(): Promise<void> {
  495. const customerQuery = new FmodeQuery('ContactInfo');
  496. const customers = await customerQuery.descending('createdAt').find();
  497. const detailItems = customers.map((customer: FmodeObject) => ({
  498. id: customer.id,
  499. name: customer.get('name') || '未命名',
  500. projects: customer.get('projectCount') || 0,
  501. lastContact: customer.get('lastContactAt') ? new Date(customer.get('lastContactAt')).toISOString().slice(0,10) : '',
  502. status: customer.get('status') || '潜在',
  503. date: customer.get('createdAt') ? new Date(customer.get('createdAt')).toISOString().slice(0,10) : ''
  504. }));
  505. this.detailData.set(detailItems);
  506. }
  507. // 加载收入详情数据
  508. private async loadRevenueDetailData(): Promise<void> {
  509. const orderQuery = new FmodeQuery('Order');
  510. orderQuery.equalTo('status', 'paid');
  511. const orders = await orderQuery.descending('createdAt').find();
  512. const detailItems = orders.map((order: FmodeObject) => ({
  513. invoiceNo: order.get('invoiceNo') || `INV-${order.id}`,
  514. customer: order.get('customer')?.get('name') || '未知客户',
  515. amount: order.get('amount') || 0,
  516. type: order.get('type') || 'service',
  517. date: order.get('createdAt') ? new Date(order.get('createdAt')).toISOString().slice(0,10) : ''
  518. }));
  519. this.detailData.set(detailItems);
  520. }
  521. // 降级到模拟详情数据
  522. private loadMockDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue'): void {
  523. const now = new Date();
  524. const addDays = (base: Date, days: number) => new Date(base.getTime() + days * 86400000);
  525. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  526. const status = type === 'active' ? '进行中' : (type === 'completed' ? '已完成' : undefined);
  527. const items = Array.from({ length: 42 }).map((_, i) => ({
  528. id: 'P' + String(1000 + i),
  529. name: `项目 ${i + 1}`,
  530. owner: ['张三','李四','王五','赵六'][i % 4],
  531. status: status || (i % 3 === 0 ? '进行中' : (i % 3 === 1 ? '已完成' : '待启动')),
  532. startDate: addDays(now, -60 + i).toISOString().slice(0,10),
  533. endDate: addDays(now, -30 + i).toISOString().slice(0,10),
  534. date: addDays(now, -i).toISOString().slice(0,10)
  535. }));
  536. this.detailData.set(items);
  537. return;
  538. }
  539. if (type === 'designers') {
  540. const items = Array.from({ length: 36 }).map((_, i) => ({
  541. id: 'D' + String(200 + i),
  542. name: ['张一','李二','王三','赵四','陈五','刘六'][i % 6],
  543. level: ['junior','mid','senior'][i % 3],
  544. completed: 10 + (i % 15),
  545. inProgress: 1 + (i % 6),
  546. avgCycle: 7 + (i % 10),
  547. date: addDays(now, -i).toISOString().slice(0,10)
  548. }));
  549. this.detailData.set(items);
  550. return;
  551. }
  552. if (type === 'customers') {
  553. const items = Array.from({ length: 28 }).map((_, i) => ({
  554. id: 'C' + String(300 + i),
  555. name: ['王先生','李女士','赵先生','陈女士'][i % 4],
  556. projects: 1 + (i % 5),
  557. lastContact: addDays(now, -i * 2).toISOString().slice(0,10),
  558. status: ['潜在','跟进中','已签约'][i % 3],
  559. date: addDays(now, -i * 2).toISOString().slice(0,10)
  560. }));
  561. this.detailData.set(items);
  562. return;
  563. }
  564. // revenue
  565. const items = Array.from({ length: 34 }).map((_, i) => ({
  566. invoiceNo: 'INV-' + String(10000 + i),
  567. customer: ['华夏地产','远景家装','绿洲装饰','宏图设计'][i % 4],
  568. amount: 5000 + (i % 12) * 1500,
  569. type: ['service','material','addon'][i % 3],
  570. date: addDays(now, -i).toISOString().slice(0,10)
  571. }));
  572. this.detailData.set(items);
  573. }
  574. getColumns(): { label: string; field: string; formatter?: (v: any) => string }[] {
  575. const type = this.detailType();
  576. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  577. return [
  578. { label: '项目编号', field: 'id' },
  579. { label: '项目名称', field: 'name' },
  580. { label: '负责人', field: 'owner' },
  581. { label: '状态', field: 'status' },
  582. { label: '开始日期', field: 'startDate' },
  583. { label: '结束日期', field: 'endDate' }
  584. ];
  585. }
  586. if (type === 'designers') {
  587. return [
  588. { label: '设计师', field: 'name' },
  589. { label: '级别', field: 'level' },
  590. { label: '完成量', field: 'completed' },
  591. { label: '进行中', field: 'inProgress' },
  592. { label: '平均周期(天)', field: 'avgCycle' },
  593. { label: '统计日期', field: 'date' }
  594. ];
  595. }
  596. if (type === 'customers') {
  597. return [
  598. { label: '客户名', field: 'name' },
  599. { label: '项目数', field: 'projects' },
  600. { label: '最后联系', field: 'lastContact' },
  601. { label: '状态', field: 'status' }
  602. ];
  603. }
  604. // revenue
  605. return [
  606. { label: '发票号', field: 'invoiceNo' },
  607. { label: '客户', field: 'customer' },
  608. { label: '金额', field: 'amount', formatter: (v: any) => this.formatCurrency(Number(v)) },
  609. { label: '类型', field: 'type' },
  610. { label: '日期', field: 'date' }
  611. ];
  612. }
  613. // 状态选项(随类型变化)
  614. getStatusOptions(): { label: string; value: string }[] {
  615. const type = this.detailType();
  616. if (type === 'totalProjects' || type === 'active' || type === 'completed') {
  617. return [
  618. { label: '全部状态', value: 'all' },
  619. { label: '进行中', value: '进行中' },
  620. { label: '已完成', value: '已完成' },
  621. { label: '待启动', value: '待启动' }
  622. ];
  623. }
  624. if (type === 'designers') {
  625. return [
  626. { label: '全部级别', value: 'all' },
  627. { label: 'junior', value: 'junior' },
  628. { label: 'mid', value: 'mid' },
  629. { label: 'senior', value: 'senior' }
  630. ];
  631. }
  632. if (type === 'customers') {
  633. return [
  634. { label: '全部状态', value: 'all' },
  635. { label: '潜在', value: '潜在' },
  636. { label: '跟进中', value: '跟进中' },
  637. { label: '已签约', value: '已签约' }
  638. ];
  639. }
  640. return [
  641. { label: '全部类型', value: 'all' },
  642. { label: 'service', value: 'service' },
  643. { label: 'material', value: 'material' },
  644. { label: 'addon', value: 'addon' }
  645. ];
  646. }
  647. // 交互:筛选与分页
  648. setKeyword(v: string) { this.keyword.set(v); this.pageIndex.set(1); }
  649. setStatus(v: string) { this.statusFilter.set(v); this.pageIndex.set(1); }
  650. setDateFrom(v: string) { this.dateFrom.set(v || null); this.pageIndex.set(1); }
  651. setDateTo(v: string) { this.dateTo.set(v || null); this.pageIndex.set(1); }
  652. resetFilters() {
  653. this.keyword.set('');
  654. this.statusFilter.set('all');
  655. this.dateFrom.set(null);
  656. this.dateTo.set(null);
  657. this.pageIndex.set(1);
  658. }
  659. get totalPages() { return this.totalPagesComputed(); }
  660. goToPage(n: number) { const tp = this.totalPagesComputed(); if (n >= 1 && n <= tp) this.pageIndex.set(n); }
  661. prevPage() { this.goToPage(this.pageIndex() - 1); }
  662. nextPage() { this.goToPage(this.pageIndex() + 1); }
  663. // 生成页码列表(最多展示 5 个,居中当前页)
  664. getPages(): number[] {
  665. const total = this.totalPagesComputed();
  666. const current = this.pageIndex();
  667. const max = 5;
  668. let start = Math.max(1, current - Math.floor(max / 2));
  669. let end = Math.min(total, start + max - 1);
  670. start = Math.max(1, end - max + 1);
  671. const pages: number[] = [];
  672. for (let i = start; i <= end; i++) pages.push(i);
  673. return pages;
  674. }
  675. // 导出当前过滤结果为 CSV
  676. exportCSV() {
  677. const cols = this.getColumns();
  678. const rows = this.filteredData();
  679. const header = cols.map(c => c.label).join(',');
  680. const escape = (val: any) => {
  681. if (val === undefined || val === null) return '';
  682. const s = String(val).replace(/"/g, '""');
  683. return /[",\n]/.test(s) ? `"${s}"` : s;
  684. };
  685. const lines = rows.map(r => cols.map(c => escape(c.formatter ? c.formatter((r as any)[c.field]) : (r as any)[c.field])).join(','));
  686. const csv = [header, ...lines].join('\n');
  687. const blob = new Blob(["\ufeff" + csv], { type: 'text/csv;charset=utf-8;' });
  688. const url = URL.createObjectURL(blob);
  689. const a = document.createElement('a');
  690. a.href = url;
  691. const filenameMap: any = { totalProjects: '项目总览', active: '进行中项目', completed: '已完成项目', designers: '设计师统计', customers: '客户统计', revenue: '收入统计' };
  692. a.download = `${filenameMap[this.detailType() || 'totalProjects']}-明细.csv`;
  693. a.click();
  694. URL.revokeObjectURL(url);
  695. }
  696. }