diff --git a/package-lock.json b/package-lock.json index 05545a0..ecbb961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.5.2", "@ant-design/nextjs-registry": "^1.0.2", + "@ant-design/plots": "^2.6.8", "@ant-design/v5-patch-for-react-19": "^1.0.3", "antd": "^5.22.0", "axios": "^1.7.7", @@ -26,6 +27,19 @@ "typescript": "^5.6.3" } }, + "node_modules/@ant-design/charts-util": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/@ant-design/charts-util/-/charts-util-0.0.3.tgz", + "integrity": "sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, "node_modules/@ant-design/colors": { "version": "7.2.1", "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", @@ -120,6 +134,24 @@ "react-dom": ">=16.0.0" } }, + "node_modules/@ant-design/plots": { + "version": "2.6.8", + "resolved": "https://registry.npmmirror.com/@ant-design/plots/-/plots-2.6.8.tgz", + "integrity": "sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==", + "license": "MIT", + "dependencies": { + "@ant-design/charts-util": "0.0.3", + "@antv/event-emitter": "^0.1.3", + "@antv/g": "^6.1.7", + "@antv/g2": "^5.2.7", + "@antv/g2-extension-plot": "^0.2.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, "node_modules/@ant-design/react-slick": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", @@ -150,6 +182,251 @@ "react-dom": ">=19.0.0" } }, + "node_modules/@antv/component": { + "version": "2.1.11", + "resolved": "https://registry.npmmirror.com/@antv/component/-/component-2.1.11.tgz", + "integrity": "sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==", + "license": "MIT", + "dependencies": { + "@antv/g": "^6.1.11", + "@antv/scale": "^0.4.16", + "@antv/util": "^3.3.10", + "svg-path-parser": "^1.1.0" + } + }, + "node_modules/@antv/component/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord": { + "version": "0.4.7", + "resolved": "https://registry.npmmirror.com/@antv/coord/-/coord-0.4.7.tgz", + "integrity": "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==", + "license": "MIT", + "dependencies": { + "@antv/scale": "^0.4.12", + "@antv/util": "^2.0.13", + "gl-matrix": "^3.4.3" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale/node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/util": { + "version": "2.0.17", + "resolved": "https://registry.npmmirror.com/@antv/util/-/util-2.0.17.tgz", + "integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==", + "license": "ISC", + "dependencies": { + "csstype": "^3.0.8", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/event-emitter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", + "integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==", + "license": "MIT" + }, + "node_modules/@antv/expr": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", + "integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==", + "license": "MIT" + }, + "node_modules/@antv/g": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/@antv/g/-/g-6.3.1.tgz", + "integrity": "sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "html2canvas": "^1.4.1" + } + }, + "node_modules/@antv/g-canvas": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@antv/g-canvas/-/g-canvas-2.2.0.tgz", + "integrity": "sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-lite": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@antv/g-lite/-/g-lite-2.7.0.tgz", + "integrity": "sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==", + "license": "MIT", + "dependencies": { + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.3", + "@babel/runtime": "^7.25.6", + "eventemitter3": "^5.0.1", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-math": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@antv/g-math/-/g-math-3.1.0.tgz", + "integrity": "sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-plugin-dragndrop": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.1.1.tgz", + "integrity": "sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g2": { + "version": "5.4.8", + "resolved": "https://registry.npmmirror.com/@antv/g2/-/g2-5.4.8.tgz", + "integrity": "sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==", + "license": "MIT", + "dependencies": { + "@antv/component": "^2.1.9", + "@antv/coord": "^0.4.7", + "@antv/event-emitter": "^0.1.3", + "@antv/expr": "^1.0.2", + "@antv/g": "^6.1.24", + "@antv/g-canvas": "^2.0.43", + "@antv/g-plugin-dragndrop": "^2.0.35", + "@antv/scale": "^0.5.1", + "@antv/util": "^3.3.10", + "@antv/vendor": "^1.0.11", + "flru": "^1.0.2", + "pdfast": "^0.2.0" + } + }, + "node_modules/@antv/g2-extension-plot": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/@antv/g2-extension-plot/-/g2-extension-plot-0.2.2.tgz", + "integrity": "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==", + "dependencies": { + "@antv/g2": "^5.1.8", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.10" + } + }, + "node_modules/@antv/scale": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.5.2.tgz", + "integrity": "sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/vendor": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@antv/vendor/-/vendor-1.0.11.tgz", + "integrity": "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.3", + "@types/d3-dispatch": "^3.0.6", + "@types/d3-dsv": "^3.0.7", + "@types/d3-ease": "^3.0.2", + "@types/d3-fetch": "^3.0.7", + "@types/d3-force": "^3.0.10", + "@types/d3-format": "^3.0.4", + "@types/d3-geo": "^3.1.0", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.0", + "@types/d3-quadtree": "^3.0.6", + "@types/d3-random": "^3.0.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-scale-chromatic": "^3.1.0", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-dispatch": "^3.0.1", + "d3-dsv": "^3.0.1", + "d3-ease": "^3.0.1", + "d3-fetch": "^3.0.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.5", + "d3-format": "^3.1.0", + "d3-geo": "^3.1.1", + "d3-geo-projection": "^4.0.0", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-quadtree": "^3.0.1", + "d3-random": "^3.0.1", + "d3-regression": "^1.3.10", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", @@ -921,6 +1198,141 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.20", "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.20.tgz", @@ -1054,6 +1466,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1099,6 +1520,22 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1111,6 +1548,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -1126,12 +1572,309 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-regression": { + "version": "1.3.10", + "resolved": "https://registry.npmmirror.com/d3-regression/-/d3-regression-1.3.10.tgz", + "integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", @@ -1233,6 +1976,33 @@ "node": ">= 0.4" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1315,6 +2085,12 @@ "node": ">= 0.4" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", @@ -1366,6 +2142,19 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1379,6 +2168,33 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1394,6 +2210,12 @@ "string-convert": "^0.2.0" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1528,6 +2350,12 @@ "node": ">= 10" } }, + "node_modules/pdfast": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/pdfast/-/pdfast-0.2.0.tgz", + "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2214,6 +3042,18 @@ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", @@ -2290,6 +3130,15 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2334,6 +3183,21 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/svg-path-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/svg-path-parser/-/svg-path-parser-1.1.0.tgz", + "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==", + "license": "MIT" + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -2376,6 +3240,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/@next/swc-win32-x64-msvc": { "version": "15.5.19", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", diff --git a/package.json b/package.json index c09af8a..16244f1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.5.2", "@ant-design/nextjs-registry": "^1.0.2", + "@ant-design/plots": "^2.6.8", "@ant-design/v5-patch-for-react-19": "^1.0.3", "antd": "^5.22.0", "axios": "^1.7.7", diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx deleted file mode 100644 index ef9c4ef..0000000 --- a/src/app/(main)/ad-audit/page.tsx +++ /dev/null @@ -1,227 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import type { ColumnsType } from 'antd/es/table'; -import { - Alert, - Button, - Card, - DatePicker, - InputNumber, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd'; -import dayjs, { type Dayjs } from 'dayjs'; -import { api, errMsg } from '@/lib/api'; -import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types'; - -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); - -const SCENE_TAG: Record = { - reward_video: { color: 'blue', label: '看视频' }, - feed: { color: 'purple', label: '信息流' }, -}; - -const STATUS_TAG: Record = { - granted: 'green', - capped: 'orange', - ecpm_missing: 'red', -}; - -const fmtFactorRange = (a: number | null, b: number | null) => { - if (a == null) return '-'; - return a === b || b == null ? String(a) : `${a}→${b}`; -}; - -const fmtIndexRange = (a: number | null, b: number | null) => { - if (a == null) return '-'; - return a === b ? String(a) : `${a}–${b}`; -}; - -export default function AdAuditPage() { - const [date, setDate] = useState(dayjs()); - const [userId, setUserId] = useState(null); - const [scene, setScene] = useState(); - const [limit, setLimit] = useState(100); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - try { - const res = await api.get('/admin/api/ad-coin-audit', { - params: { - date: date.format('YYYY-MM-DD'), - user_id: userId ?? undefined, - scene: scene ?? undefined, - limit, - }, - }); - setData(res.data); - } catch (e) { - message.error(errMsg(e)); - } finally { - setLoading(false); - } - }, [date, userId, scene, limit]); - - useEffect(() => { - load(); - // 仅首次自动拉今天;之后由「查询」按钮触发 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const columns: ColumnsType = [ - { - title: '场景', - dataIndex: 'scene', - width: 90, - render: (s: string) => { - const t = SCENE_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; - }, - }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 175 }, - { title: '用户', dataIndex: 'user_id', width: 70 }, - { - title: '状态', - dataIndex: 'status', - width: 110, - render: (s: string) => {s}, - }, - { title: 'eCPM', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' }, - { - title: '因子1', - dataIndex: 'ecpm_factor', - width: 70, - render: (v: number | null) => v ?? '-', - }, - { title: '份数', dataIndex: 'units', width: 60 }, - { - title: 'LT累计条数', - key: 'lt_index', - width: 100, - render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), - }, - { - title: '因子2', - key: 'lt_factor', - width: 90, - render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), - }, - { - title: '应发金币', - dataIndex: 'expected_coin', - width: 100, - render: (v: number) => {v}, - }, - { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, - { - title: '一致', - dataIndex: 'matched', - width: 70, - render: (m: boolean) => - m ? : ✗ 不符, - }, - ]; - - return ( -
-

看广告金币审计

- - 把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。 - - - {data && ( - - 金币公式:{' '} - {data.formula.description} -
-
- eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '} - {data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。 -
-
- 因子1(按 eCPM 元判档,阈值单位:元): - {data.formula.ecpm_factor_tiers - .map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`) - .join(' | ')} -
-
- 因子2(LT 累计条数): - {data.formula.lt_factor_tiers - .map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`) - .join(' | ')} -
-
-
- )} - - - d && setDate(d)} allowClear={false} /> - setUserId(v ?? null)} - style={{ width: 140 }} - /> - ({ value: n, label: `${n} 条` }))} - /> - - - - {data && ( -
- {data.mismatch_count > 0 ? ( - - ) : ( - - )} -
- )} - - `${r.scene}-${r.record_id}`} - columns={columns} - dataSource={data?.items ?? []} - loading={loading} - pagination={false} - size="small" - scroll={{ x: 1100 }} - rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} - /> - - - ); -} diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx new file mode 100644 index 0000000..c6cb94e --- /dev/null +++ b/src/app/(main)/ad-revenue/page.tsx @@ -0,0 +1,840 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import { + App, + Alert, + Button, + Card, + Col, + DatePicker, + Divider, + Form, + Input, + InputNumber, + Modal, + Popover, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Typography, +} from 'antd'; +import { + FallOutlined, + InfoCircleOutlined, + QuestionCircleOutlined, + RiseOutlined, +} from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; +import type { + AdRevenueDaily, + AdRevenueImpression, + AdRevenueRecord, + AdRevenueReport, + AdRevenueRow, +} from '@/lib/types'; + +const { RangePicker } = DatePicker; + +// 广告类型标签 +const TYPE_TAG: Record = { + reward_video: { color: 'blue', label: '激励视频' }, + feed: { color: 'purple', label: '信息流' }, + draw: { color: 'geekblue', label: 'Draw 信息流' }, +}; + +// 我们的应用环境标签 +const APP_TAG: Record = { + prod: { color: 'green', label: '傻瓜比价(正式)' }, + test: { color: 'default', label: '测试应用' }, +}; + +// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限) +const STATUS_TAG: Record = { + granted: { color: 'green', label: '已发' }, + capped: { color: 'orange', label: '次数超限' }, + ecpm_missing: { color: 'red', label: '缺 eCPM' }, + too_short: { color: 'gold', label: '未满10秒' }, + closed_early: { color: 'default', label: '提前关闭' }, +}; + +const fmtFactorRange = (a: number | null, b: number | null) => { + if (a == null) return '-'; + return a === b || b == null ? String(a) : `${a}→${b}`; +}; + +const fmtIndexRange = (a: number | null, b: number | null) => { + if (a == null) return '-'; + return a === b ? String(a) : `${a}–${b}`; +}; + +// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。 +// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。 +const COIN_PER_YUAN = 10000; +// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE +const ECPM_FACTOR_ROWS = [ + { key: '4', range: '> 400', factor: 0.6 }, + { key: '3', range: '201 – 400', factor: 0.4 }, + { key: '2', range: '101 – 200', factor: 0.3 }, + { key: '1', range: '0 – 100', factor: 0.1 }, +]; +// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE +const LT_FACTOR_ROWS = [ + { key: '1', lt: '第 1 条', factor: 2.0 }, + { key: '2', lt: '第 2 条', factor: 1.5 }, + { key: '3', lt: '第 3 条', factor: 1.3 }, + { key: '4', lt: '4 – 10 条', factor: 1.1 }, + { key: '5', lt: '第 11 条及以后', factor: 1.0 }, +]; + +// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位) +const IMPRESSION_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, + { title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 }, + { title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) }, + { + title: 'adn', + dataIndex: 'adn', + width: 90, + render: (v: string | null) => v ?? '-', + }, + { + title: '底层代码位ID', + dataIndex: 'slot_id', + render: (v: string | null) => v ?? '-', + }, +]; + +// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列) +const DETAIL_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (s: string) => { + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' }, + { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, + { title: '份数', dataIndex: 'units', width: 60 }, + { + title: 'LT累计条数', + key: 'lt_index', + width: 100, + render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end), + }, + { + title: '因子2', + key: 'lt_factor', + width: 90, + render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + }, + { title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => {v} }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + render: (m: boolean) => (m ? : ✗ 不符), + }, +]; + +// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。 +const CHART_BAR = '#69b1ff'; +const CHART_LINE = '#fa8c16'; + +interface TrendPoint { + label: string; // x 轴刻度文案(小时数 / MM-DD) + tip: string; // hover 原生 tooltip 全文 + impressions: number; + revenue: number; +} + +// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告) +function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] { + const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 })); + for (const r of rows) { + if (r.hour == null || r.hour < 0 || r.hour > 23) continue; + byHour[r.hour].impressions += r.impressions; + byHour[r.hour].revenue += r.revenue_yuan; + } + return byHour.map((b) => ({ + label: String(b.hour), + tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)} 元`, + impressions: b.impressions, + revenue: b.revenue, + })); +} + +// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续 +function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] { + const map = new Map(daily.map((d) => [d.date, d])); + const out: TrendPoint[] = []; + let cur = dayjs(dateFrom); + let guard = 0; + while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) { + const ds = cur.format('YYYY-MM-DD'); + const d = map.get(ds); + const impressions = d?.impressions ?? 0; + const revenue = d?.revenue_yuan ?? 0; + out.push({ + label: ds.slice(5), + tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)} 元`, + impressions, + revenue, + }); + cur = cur.add(1, 'day'); + guard += 1; + } + return out; +} + +function TrendChart({ points }: { points: TrendPoint[] }) { + const n = points.length; + const maxImp = Math.max(1, ...points.map((p) => p.impressions)); + const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue)); + + const W = 960; + const H = 280; + const padL = 48; + const padR = 56; + const padT = 16; + const padB = 32; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + const step = plotW / Math.max(1, n); + const barW = Math.min(28, step * 0.55); + const yBase = padT + plotH; + + const barX = (i: number) => padL + i * step + (step - barW) / 2; + const cx = (i: number) => padL + i * step + step / 2; + const impH = (v: number) => (v / maxImp) * plotH; + const revY = (v: number) => yBase - (v / maxRev) * plotH; + const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' '); + const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤 + + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = yBase - t * plotH; + return ( + + + + {Math.round(maxImp * t)} + + + {(maxRev * t).toFixed(2)} + + + ); + })} + {points.map((p, i) => ( + + {p.tip} + + ))} + + {points.map((p, i) => ( + + {p.tip} + + ))} + {points.map((p, i) => + i % labelEvery === 0 || i === n - 1 ? ( + + {p.label} + + ) : null, + )} + + ); +} + +// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 +// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 +// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 +// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。 +interface AdCfg { + app_id: string; + reward_code_id: string; + compare_feed_code_id: string; + coupon_feed_code_id: string; + reward_mkey: string; + reward_enabled: boolean; + compare_ad_enabled: boolean; + coupon_ad_enabled: boolean; +} + +function AdConfigPanel() { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const load = useCallback(async () => { + try { + const r = await api.get('/admin/api/ad-config'); + form.setFieldsValue(r.data); + } catch (e) { + message.error(errMsg(e, '广告配置加载失败')); + } + }, [form, message]); + useEffect(() => { + load(); + }, [load]); + const save = async () => { + const v = await form.validateFields(); + setSaving(true); + try { + await api.patch('/admin/api/ad-config', v); + message.success('已保存。客户端下次拉取生效(应用ID需冷启,广告位ID即时)'); + load(); + } catch (e) { + message.error(errMsg(e, '保存失败')); + } finally { + setSaving(false); + } + }; + return ( + + 保存 + + } + > + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export default function AdRevenuePage() { + const { message } = App.useApp(); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + const [userId, setUserId] = useState(null); + const [adType, setAdType] = useState(); + const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); + const [limit, setLimit] = useState(500); + const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 + const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 + const [data, setData] = useState(null); + const [queriedLimit, setQueriedLimit] = useState(500); + const [loading, setLoading] = useState(false); + const [formulaOpen, setFormulaOpen] = useState(false); + + // 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天 + const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD'); + + const load = useCallback(async () => { + const from = range[0].format('YYYY-MM-DD'); + const to = range[1].format('YYYY-MM-DD'); + const multiDay = from !== to; + const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 + setLoading(true); + try { + const res = await api.get('/admin/api/ad-revenue-report', { + params: { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + ad_type: adType ?? undefined, + granularity: gran, + limit, + }, + }); + setData(res.data); + setQueriedLimit(limit); + setQueriedGranularity(gran); + setQueriedMultiDay(multiDay); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, [range, userId, adType, granularity, limit]); + + useEffect(() => { + load(); + // 仅首次自动拉今天;之后由「查询」按钮触发 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns: ColumnsType = [ + ...(queriedMultiDay + ? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType[number]] + : []), + { title: '用户', dataIndex: 'user_id', width: 80 }, + ...(queriedGranularity === 'hour' + ? [ + { + title: '小时', + dataIndex: 'hour', + width: 90, + render: (h: number | null) => + h == null ? '-' : `${String(h).padStart(2, '0')}:00`, + } as ColumnsType[number], + ] + : []), + { + title: '广告类型', + dataIndex: 'ad_type', + width: 120, + render: (s: string) => { + const t = TYPE_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '来源应用', + dataIndex: 'app_env', + width: 150, + render: (v: string | null) => { + if (!v) return 未知; + const t = APP_TAG[v] ?? { color: 'default', label: v }; + return {t.label}; + }, + }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 120, + render: (v: string | null) => + v ? {v} : -, + }, + { title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' }, + { + title: '预估收益(元)', + dataIndex: 'revenue_yuan', + width: 120, + align: 'right', + render: (v: number) => v.toFixed(4), + }, + { + title: '应发金币', + dataIndex: 'expected_coin', + width: 100, + align: 'right', + render: (v: number) => {v}, + }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + align: 'center', + render: (m: boolean) => (m ? : ✗ 不符), + }, + { + title: '底层 ADN', + dataIndex: 'adns', + render: (v: string[]) => + v.length ? v.map((a) => {a}) : -, + }, + ]; + + // 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准): + // 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本; + // 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。 + const derived = data + ? { + avgEcpm: data.total_impressions + ? (data.total_revenue_yuan / data.total_impressions) * 1000 + : 0, + payoutYuan: data.total_actual_coin / COIN_PER_YUAN, + grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN, + payoutRatioPct: + data.total_revenue_yuan > 0 + ? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100 + : null, + coinGap: data.total_expected_coin - data.total_actual_coin, + } + : null; + + return ( +
+ + +

收益报表

+ + + 「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000 + 累加),只要广告展示就计入、不论是否看完发奖;穿山甲会过滤无效/过短曝光,故预估值可能偏高, + 实际收益一律以穿山甲后台结算为准。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。 +
+ } + > + + + + + + + + 日期 + v && v[0] && v[1] && setRange([v[0], v[1]])} + allowClear={false} + presets={[ + { label: '今天', value: [dayjs(), dayjs()] }, + { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, + { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, + ]} + /> + + + 用户 + setUserId(v ?? null)} + style={{ width: 130 }} + /> + + + 类型 + + + + 条数 +
`${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`} + columns={columns} + dataSource={data?.items ?? []} + loading={loading} + pagination={false} + size="small" + scroll={{ x: 1100 }} + rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} + expandable={{ + rowExpandable: (r) => + (r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0, + expandedRowRender: (r) => { + const imps = r.impression_records ?? []; + const recs = r.records ?? []; + return ( + +
+ 展示明细{' '} + ({imps.length} 条) + + style={{ marginTop: 8 }} + rowKey="id" + columns={IMPRESSION_COLUMNS} + dataSource={imps} + pagination={false} + size="small" + scroll={{ x: 700 }} + /> +
+
+ 发奖明细{' '} + ({recs.length} 条) + + style={{ marginTop: 8 }} + rowKey="record_id" + columns={DETAIL_COLUMNS} + dataSource={recs} + pagination={false} + size="small" + scroll={{ x: 900 }} + rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} + locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }} + /> +
+
+ ); + }, + }} + /> + setFormulaOpen(false)} + footer={null} + width={680} + > + + 单次奖励(元) =(单次 eCPM ÷ 1000)× 因子1(eCPM 档)× 因子2(LT 累计条数) + + + · eCPM 取自穿山甲 SDK getEcpm() 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。 +
· 金币汇率:1 元 = {COIN_PER_YUAN.toLocaleString()} 金币,最终四舍五入取整。 +
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。 +
· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。 +
+ +
+ 因子1 ·按 eCPM(元/千次)判档 +
+ + + 因子2 ·按账号 LT 累计条数 +
+ + + + 示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。 + + + + + + ); +} diff --git a/src/app/(main)/admins/page.tsx b/src/app/(main)/admins/page.tsx index f3ae92e..4750ff3 100644 --- a/src/app/(main)/admins/page.tsx +++ b/src/app/(main)/admins/page.tsx @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd'; +import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd'; import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; import type { AdminInfo } from '@/lib/types'; const ROLES = [ @@ -11,9 +12,11 @@ const ROLES = [ { value: 'finance', label: 'finance' }, { value: 'operator', label: 'operator' }, ]; -const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未'); +// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未'); export default function AdminsPage() { + const { message, modal } = App.useApp(); const [admins, setAdmins] = useState([]); const [loading, setLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -47,7 +50,7 @@ export default function AdminsPage() { const changeRole = (a: AdminInfo, role: string) => { if (role === a.role) return; - Modal.confirm({ + modal.confirm({ title: `把 ${a.username} 的角色改为 ${role}?`, onOk: async () => { try { @@ -63,7 +66,7 @@ export default function AdminsPage() { const toggle = (a: AdminInfo) => { const next = a.status === 'active' ? 'disabled' : 'active'; - Modal.confirm({ + modal.confirm({ title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`, onOk: async () => { try { diff --git a/src/app/(main)/audit-logs/page.tsx b/src/app/(main)/audit-logs/page.tsx index ebe1f93..91fd662 100644 --- a/src/app/(main)/audit-logs/page.tsx +++ b/src/app/(main)/audit-logs/page.tsx @@ -3,19 +3,18 @@ import { useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { Button, Input, Space, Table, Tag } from 'antd'; -import { useCursorList } from '@/lib/useCursorList'; +import { formatUtcTime } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +// 审计 created_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string) => formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss'); export default function AuditLogsPage() { const [action, setAction] = useState(''); const [filters, setFilters] = useState>({}); - const { items, nextCursor, loading, loadMore } = useCursorList( - '/admin/api/audit-logs', - filters, - 50, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange } = + usePagedList('/admin/api/audit-logs', filters, 50); const search = () => setFilters({ action: action || undefined }); @@ -59,14 +58,16 @@ export default function AuditLogsPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: onPageChange, + }} scroll={{ x: 1000 }} /> -
- -
); } diff --git a/src/app/(main)/comparison-records/page.tsx b/src/app/(main)/comparison-records/page.tsx new file mode 100644 index 0000000..113b9ac --- /dev/null +++ b/src/app/(main)/comparison-records/page.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useState } from 'react'; +import type { CSSProperties } from 'react'; +import { + App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber, + Select, Space, Spin, Table, Tag, Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { api, errMsg } from '@/lib/api'; +import { formatWallTime, yuan } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; +import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types'; + +const STATUS_COLOR: Record = { success: 'green', failed: 'red', cancelled: 'default' }; +const STATUS_LABEL: Record = { success: '成功', failed: '失败', cancelled: '已终止' }; + +// 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价) +const STUCK_LABEL: Record = { + success: '成功', + store_not_found: '没找到店', + items_not_found: '菜没匹配上', + below_minimum: '未达起送', + unsupported: '平台不支持', + failed: '失败', +}; + +const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`); +const cents = (c: number | null) => (c == null ? '-' : yuan(c)); + +// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。 +function pretty(s: string | null): string { + if (!s) return ''; + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +const preStyle: CSSProperties = { + background: '#f6f8fa', padding: 8, borderRadius: 6, fontSize: 12, + maxHeight: 340, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, +}; + +export default function ComparisonRecordsPage() { + const { message } = App.useApp(); + + const [userId, setUserId] = useState(null); + const [phone, setPhone] = useState(''); + const [status, setStatus] = useState(); + const [applied, setApplied] = useState>({}); + + const { items, total, page, pageSize, loading, onChange } = + usePagedList('/admin/api/comparison-records', applied); + + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const search = () => + setApplied({ user_id: userId ?? undefined, phone: phone || undefined, status }); + + const reset = () => { + setUserId(null); + setPhone(''); + setStatus(undefined); + setApplied({}); + }; + + const openDetail = async (id: number) => { + setDetailLoading(true); + setDetail(null); + try { + const { data } = await api.get(`/admin/api/comparison-records/${id}`); + setDetail(data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setDetailLoading(false); + } + }; + + const closeDetail = () => { + setDetail(null); + setDetailLoading(false); + }; + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 64 }, + { + title: '用户', + key: 'user', + width: 140, + render: (_, r) => ( +
+
{r.phone || `#${r.user_id}`}
+ {r.nickname &&
{r.nickname}
} +
+ ), + }, + { title: '状态', dataIndex: 'status', width: 72, render: (s: string) => {STATUS_LABEL[s] || s} }, + { title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' }, + { title: '店/商品', dataIndex: 'store_name', width: 150, ellipsis: true, render: (v) => v || '-' }, + { title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' }, + { + title: '省', + key: 'saved', + width: 84, + render: (_, r) => + r.saved_amount_cents == null ? '-' : ( + 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)} + ), + }, + { title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs }, + { title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' }, + { title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' }, + { + title: '重试', + dataIndex: 'retry_count', + width: 50, + render: (v: number | null) => (v ? {v} : v ?? '-'), + }, + { + title: '机型/ROM', + key: 'device', + width: 150, + ellipsis: true, + render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-', + }, + { title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 80, + render: (_, r) => + r.trace_url ? ( + e.stopPropagation()}>trace + ) : ( + - + ), + }, + ]; + + const platformResults = + (detail?.raw_payload?.platform_results as Record[] | undefined) || []; + + return ( +
+

比价记录(debug)

+ + + setPhone(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> +
`共 ${t} 条`, + onChange, + }} + scroll={{ x: 1320 }} + onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })} + /> + + + {detailLoading && } + {detail && ( + + + + {detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''} + + {STATUS_LABEL[detail.status] || detail.status} + {detail.business_type} + {fmtMs(detail.total_ms)} + {detail.step_count ?? '-'} + {detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'} + {detail.source_platform_name || '-'}({cents(detail.source_price_cents)}) + {detail.best_platform_name || '-'}({cents(detail.best_price_cents)}) + {cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''} + {detail.store_name || '-'} + {detail.information || '-'} + + {detail.trace_url + ? {detail.trace_url} + : {detail.trace_id}(无 trace_url)} + + + {detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'} + + + {detail.best_deeplink + ? {detail.best_deeplink} + : '-'} + + + + + + {detail.device_model || '-'}({detail.device_manufacturer || '-'}) + {[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'} + {detail.android_version || '-'}(SDK {detail.android_sdk ?? '-'}) + {detail.app_version || '-'}({detail.app_version_code ?? '-'}) + {detail.source_app_version || '-'} + {detail.device_id || '-'} + + + + {((detail.items?.length ?? 0) > 0 || (detail.skipped_dish_names?.length ?? 0) > 0) && ( + + {(detail.items?.length ?? 0) > 0 ? ( +
    + {detail.items.map((it, i) => ( +
  • + {it.name}{it.qty != null ? ` ×${it.qty}` : ''} + {it.specs?.length ? ({it.specs.join('/')}) : null} +
  • + ))} +
+ ) : 无商品明细} + {(detail.skipped_dish_names?.length ?? 0) > 0 && ( +
+ 跳过{detail.skipped_dish_count != null ? ` ${detail.skipped_dish_count} ` : ''}个:{detail.skipped_dish_names.join('、')} +
+ )} +
+ )} + + {platformResults.length > 0 && ( + +
String(i)} + pagination={false} + dataSource={platformResults} + columns={[ + { title: '平台', dataIndex: 'platform_name', render: (v, r: Record) => (v as string) || (r.platform_id as string) || '-' }, + { title: '结局', dataIndex: 'status', render: (s: string) => {STUCK_LABEL[s] || s || '-'} }, + { title: '说明', dataIndex: 'reason', render: (v) => (v as string) || '-' }, + ]} + /> + + )} + + {(detail.comparison_results?.length ?? 0) > 0 && ( + {JSON.stringify(detail.comparison_results, null, 2)}, + }]} + /> + )} + + + {detail.llm_calls && detail.llm_calls.length > 0 ? ( + ({ + key: String(i), + label: ( + + {c.scene || 'llm'} + {c.model} + {c.latency_ms ?? '-'}ms + {c.usage?.total_tokens ? {c.usage.total_tokens} tok : null} + {c.error ? error : null} + + ), + children: ( +
+ {c.input_messages.map((m, j) => ( +
+ {m.role} +
{pretty(m.content)}
+
+ ))} +
+ output +
{c.error ? `【error】${c.error}` : pretty(c.output)}
+
+
+ ), + }))} + /> + ) : ( + 无 LLM 调用明细(旧记录 / pricebot 未采集到 / 本次未调 LLM) + )} +
+ + {JSON.stringify(detail.raw_payload, null, 2)}, + }]} + /> + + )} + + + ); +} diff --git a/src/app/(main)/config/page.tsx b/src/app/(main)/config/page.tsx index 54069a5..4df3570 100644 --- a/src/app/(main)/config/page.tsx +++ b/src/app/(main)/config/page.tsx @@ -1,14 +1,14 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd'; +import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd'; import { api, errMsg } from '@/lib/api'; interface ConfigItem { key: string; label: string; group: string; - type: string; // int / int_list / dict_str_int + type: string; // int / int_list / dict_str_int / bool help: string | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any default: any; @@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any function fromEdit(type: string, raw: any): any { if (type === 'int') return Number(raw); + if (type === 'bool') return Boolean(raw); if (type === 'int_list') { return String(raw) .split(',') @@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any { } export default function ConfigPage() { + const { message } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -109,7 +111,12 @@ export default function ConfigPage() { )} - {item.type === 'int' ? ( + {item.type === 'bool' ? ( + setEdits({ ...edits, [item.key]: v })} + /> + ) : item.type === 'int' ? ( setEdits({ ...edits, [item.key]: v })} diff --git a/src/app/(main)/cps/groups/[id]/page.tsx b/src/app/(main)/cps/groups/[id]/page.tsx new file mode 100644 index 0000000..7c616e9 --- /dev/null +++ b/src/app/(main)/cps/groups/[id]/page.tsx @@ -0,0 +1,331 @@ +'use client'; + +// CPS 群对账详情:该群点击量/独立点击/复制/独立复制的天级 or 小时级变化折线图。 +// 时间范围: 近 3/7/14/30 天(天级) 或 今日(小时级)。数据由后端补零成连续桶,折线不断裂。 +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import dynamic from 'next/dynamic'; +import type { ColumnsType } from 'antd/es/table'; +import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { formatUtcTime, yuan } from '@/lib/format'; + +// @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。 +const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false }); + +interface TsPoint { + label: string; + click_pv: number; + click_uv: number; + copy_pv: number; + copy_uv: number; +} +interface TsResp { + group_id: number; + group_name: string; + granularity: string; + points: TsPoint[]; +} + +// 按天大表格一行(订单侧字段对淘宝/京东群为 null → 显示 "-") +interface DailyRow { + date: string; + click_pv: number; + click_uv: number; + copy_pv: number; + order_count: number | null; + canceled_count: number | null; + gmv_cents: number | null; + est_commission_cents: number | null; + refund_profit_cents: number | null; + settled_commission_cents: number | null; +} +interface DailyResp { + group_name: string; + is_meituan: boolean; + days: number; + rows: DailyRow[]; +} + +// 群内微信用户(落地页授权拿到;nickname/headimgurl 仅 userinfo 授权后有) +interface WxUser { + openid: string; + nickname: string | null; + headimgurl: string | null; + first_seen: string; + visit_count: number; + copy_count: number; +} + +const RANGES = [ + { label: '近 3 天', value: 'd3' }, + { label: '近 7 天', value: 'd7' }, + { label: '近 14 天', value: 'd14' }, + { label: '近 30 天', value: 'd30' }, + { label: '今日(按小时)', value: 'h' }, +]; + +const METRICS: { key: keyof Omit; name: string }[] = [ + { key: 'click_pv', name: '点击量' }, + { key: 'click_uv', name: '独立点击' }, + { key: 'copy_pv', name: '复制次数' }, + { key: 'copy_uv', name: '独立复制' }, +]; + +function rangeToQuery(r: string): string { + if (r === 'h') return 'granularity=hour'; + const days: Record = { d3: 3, d7: 7, d14: 14, d30: 30 }; + return `granularity=day&days=${days[r] ?? 7}`; +} + +// 大表格只按天:天级范围 → 天数;今日小时(h) → null(此时隐藏表格,小时维度看上面折线) +function rangeToDays(r: string): number | null { + return ({ d3: 3, d7: 7, d14: 14, d30: 30 } as Record)[r] ?? null; +} + +export default function GroupDetailPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + const [range, setRange] = useState('d7'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const r = await api.get( + `/admin/api/cps/groups/${id}/timeseries?${rangeToQuery(range)}`, + ); + setData(r.data); + } catch (e) { + message.error(errMsg(e, '加载失败')); + } finally { + setLoading(false); + } + }, [id, range]); + + useEffect(() => { + load(); + }, [load]); + + // 按天大表格(独立于折线;今日小时模式 tableDays=null 时不取、不显示) + const [daily, setDaily] = useState(null); + const tableDays = rangeToDays(range); + const loadDaily = useCallback(async () => { + if (tableDays == null) return; + try { + const r = await api.get(`/admin/api/cps/groups/${id}/daily?days=${tableDays}`); + setDaily(r.data); + } catch (e) { + message.error(errMsg(e, '明细加载失败')); + } + }, [id, tableDays]); + useEffect(() => { + loadDaily(); + }, [loadDaily]); + + // 群内微信用户(独立于时间范围,累计画像) + const [wxUsers, setWxUsers] = useState([]); + const loadWxUsers = useCallback(async () => { + try { + const r = await api.get<{ users: WxUser[] }>(`/admin/api/cps/groups/${id}/wx-users`); + setWxUsers(r.data.users || []); + } catch (e) { + message.error(errMsg(e, '用户列表加载失败')); + } + }, [id]); + useEffect(() => { + loadWxUsers(); + }, [loadWxUsers]); + + const points = useMemo(() => data?.points ?? [], [data]); + // 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。 + const chartData = useMemo( + () => + points.flatMap((p) => + METRICS.map((m) => ({ label: p.label, 指标: m.name, value: Number(p[m.key]) })), + ), + [points], + ); + const totals = useMemo( + () => METRICS.map((m) => ({ name: m.name, sum: points.reduce((s, p) => s + Number(p[m.key]), 0) })), + [points], + ); + const hasData = points.some((p) => p.click_pv || p.click_uv || p.copy_pv || p.copy_uv); + + const config = { + data: chartData, + xField: 'label', + yField: 'value', + colorField: '指标', + height: 420, + legend: { color: { position: 'top' as const } }, + // 折线带数据点(每桶一个);大小用 style.r 固定 —— sizeField/shapeField 是数据字段映射, + // 给常量会被 G2 当字段名找而失效。 + point: { style: { r: 3 } }, + // y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。 + scale: { y: { domainMin: 0, nice: true } }, + axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } }, + interaction: { tooltip: { shared: true } }, + }; + + const dailyColumns: ColumnsType = [ + { title: '日期', dataIndex: 'date', width: 64, fixed: 'left' }, + { title: '点击', dataIndex: 'click_pv', width: 56 }, + { title: '独立', dataIndex: 'click_uv', width: 56 }, + { title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' }, + { + title: '点击→下单', + key: 'rate', + width: 86, + render: (_, r) => + r.order_count == null + ? '-' + : r.click_uv + ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` + : '-', + }, + { title: '有效单', dataIndex: 'order_count', width: 64, render: (v: number | null) => (v == null ? '-' : v) }, + { + title: '取消/风控', + dataIndex: 'canceled_count', + width: 78, + render: (v: number | null) => (v == null ? '-' : v ? {v} : 0), + }, + { title: '成交额', dataIndex: 'gmv_cents', width: 84, render: (v: number | null) => (v == null ? '-' : yuan(v)) }, + { + title: '预估佣金', + dataIndex: 'est_commission_cents', + width: 84, + render: (v: number | null) => (v == null ? '-' : yuan(v)), + }, + { + title: '退款佣金', + dataIndex: 'refund_profit_cents', + width: 84, + render: (v: number | null) => + v == null ? '-' : v ? {yuan(v)} : yuan(0), + }, + { + title: '净佣金', + key: 'net', + width: 84, + render: (_, r) => + r.est_commission_cents == null ? ( + '-' + ) : ( + {yuan(r.est_commission_cents - (r.refund_profit_cents || 0))} + ), + }, + { + title: '结算佣金', + dataIndex: 'settled_commission_cents', + width: 84, + render: (v: number | null) => (v == null ? '-' : {yuan(v)}), + }, + ]; + + const wxColumns: ColumnsType = [ + { + title: '用户', + key: 'user', + render: (_, u) => ( + + {u.headimgurl ? ( + + ) : ( + + )} + {u.nickname || '(未授权昵称)'} + + ), + }, + { + title: '领券次数', + dataIndex: 'copy_count', + width: 90, + sorter: (a, b) => a.copy_count - b.copy_count, + render: (v: number) => (v ? {v} : 0), + }, + { title: '点击次数', dataIndex: 'visit_count', width: 90 }, + { title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) }, + ]; + + return ( +
+ + + + 群「{data?.group_name ?? ''}」点击趋势 + + + + + setRange(v as string)} /> + + + + {totals.map((t) => ( +
+ + + + + ))} + + + + + {hasData ? ( + + ) : ( + + )} + + + + {tableDays != null && ( + + {daily && !daily.is_meituan && ( +
+ 该群非美团渠道,订单/佣金类无对账数据,显示「-」 +
+ )} + + rowKey="date" + size="small" + columns={dailyColumns} + dataSource={daily?.rows ?? []} + pagination={false} + scroll={{ x: 900 }} + /> +
+ )} + + +
+ 在微信里打开本群落地页并授权的用户。「领券次数」= 点了几次「复制口令」;昵称/头像需用户点领券时授权补充。 +
+ + rowKey="openid" + size="small" + columns={wxColumns} + dataSource={wxUsers} + pagination={false} + scroll={{ x: 560 }} + locale={{ emptyText: '暂无授权用户' }} + /> +
+ + ); +} diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx new file mode 100644 index 0000000..3be7648 --- /dev/null +++ b/src/app/(main)/cps/page.tsx @@ -0,0 +1,963 @@ +'use client'; + +// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。 +// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。 +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import type { ColumnsType } from 'antd/es/table'; +import { + Button, + Card, + Checkbox, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Statistic, + Table, + Tabs, + Tag, + Typography, + Upload, + message, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { formatUtcTime, yuan } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; +import type { + CpsActivity, + CpsGroup, + CpsGroupStat, + CpsOrder, + CpsReconcileResult, + CpsReferralLinks, + CpsStats, +} from '@/lib/types'; + +const MT_STATUS: Record = { + '2': { text: '已付款', color: 'blue' }, + '3': { text: '已完成', color: 'cyan' }, + '4': { text: '已取消', color: 'red' }, + '5': { text: '风控', color: 'orange' }, + '6': { text: '已结算', color: 'green' }, +}; + +const PLATFORM_LABEL: Record = { meituan: '美团', taobao: '淘宝', jd: '京东' }; +const PLATFORM_COLOR: Record = { meituan: 'gold', taobao: 'volcano', jd: 'red' }; +const PLATFORM_OPTIONS = [ + { label: '美团', value: 'meituan' }, + { label: '淘宝', value: 'taobao' }, + { label: '京东', value: 'jd' }, +]; + +const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-'); +const DASH = -; +const platformTags = (ps: string[] | undefined) => + (ps || []).map((p) => ( + + {PLATFORM_LABEL[p] || p} + + )); + +export default function CpsPage() { + return ( +
+

CPS 优惠券分发与对账

+ + 美团(actId + sid,可完整对账下单/佣金) / 淘宝(淘口令) / 京东(链接)。淘宝、京东无开放 API, + 只统计点击,对账字段显示「-」。发群链接都是咱的落地页 /c/xxx。 + + }, + { key: 'groups', label: '群管理', children: }, + { key: 'activities', label: '活动管理', children: }, + { key: 'orders', label: '订单明细', children: }, + ]} + /> +
+ ); +} + +// ───────────── 对账统计 ───────────── +function StatsTab() { + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [reconciling, setReconciling] = useState(false); + const canReconcile = canDo(['finance']); + + const load = useCallback(async (d: number) => { + setLoading(true); + try { + const resp = await api.get('/admin/api/cps/stats', { params: { days: d } }); + setData(resp.data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(days); + }, [days, load]); + + const reconcile = async () => { + setReconciling(true); + try { + const resp = await api.post('/admin/api/cps/orders/reconcile', null, { + params: { days }, + }); + const r = resp.data; + message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`); + load(days); + } catch (e) { + message.error(errMsg(e)); + } finally { + setReconciling(false); + } + }; + + const columns: ColumnsType = [ + { + title: '群', + dataIndex: 'name', + width: 140, + ellipsis: true, + fixed: 'left', + render: (v: string, r) => + r.group_id ? {v} : v, + }, + { title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) }, + { title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' }, + { title: '点击', dataIndex: 'click_pv', width: 64 }, + { title: '独立', dataIndex: 'click_uv', width: 64 }, + { + title: '复制', + dataIndex: 'copy_pv', + width: 64, + render: (v: number) => (v ? v : DASH), + }, + { + title: '有效单', + dataIndex: 'order_count', + width: 70, + render: (v: number | null) => (v == null ? DASH : v), + }, + { + title: '取消/风控', + dataIndex: 'canceled_count', + width: 80, + render: (v: number | null) => (v == null ? DASH : v ? {v} : 0), + }, + { + title: '点击→下单', + key: 'rate', + width: 88, + render: (_, r) => + r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-', + }, + { + title: '成交额', + dataIndex: 'gmv_cents', + width: 96, + render: (v: number | null) => (v == null ? DASH : yuan(v)), + }, + { + title: '预估佣金', + dataIndex: 'est_commission_cents', + width: 100, + sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0), + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), + }, + { + title: '已结算佣金', + dataIndex: 'settled_commission_cents', + width: 100, + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), + }, + ]; + + return ( +
+ + 统计区间 +
r.sid ?? `gid-${r.group_id ?? 'none'}`} + columns={columns} + dataSource={data?.groups ?? []} + loading={loading} + pagination={false} + scroll={{ x: 1200 }} + /> + + ); +} + +// ───────────── 群管理 ───────────── +function GroupsTab() { + const [keyword, setKeyword] = useState(''); + const [applied, setApplied] = useState>({}); + const { items, total, page, pageSize, loading, onChange, reload } = usePagedList( + '/admin/api/cps/groups', + applied, + ); + const canManage = canDo(['operator']); + + const [editing, setEditing] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [linkGroup, setLinkGroup] = useState(null); + + const delGroup = async (g: CpsGroup) => { + try { + await api.delete(`/admin/api/cps/groups/${g.id}`); + message.success('已删除群'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '群名', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) }, + { + title: 'sid', + dataIndex: 'sid', + width: 130, + render: (v: string | null) => (v ? {v} : DASH), + }, + { title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string) => {s}, + }, + { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 200, + render: (_, g) => ( + e.stopPropagation()}> + {canManage && setLinkGroup(g)}>生成链接} + {canManage && setEditing(g)}>编辑} + {canManage && ( + delGroup(g)} + > + 删除 + + )} + + ), + }, + ]; + + return ( +
+ + setKeyword(e.target.value)} + onPressEnter={() => setApplied({ keyword: keyword || undefined })} + allowClear + style={{ width: 180 }} + /> + + {canManage && } + + +
`共 ${t} 个群`, + onChange, + }} + scroll={{ x: 1090 }} + /> + + setCreateOpen(false)} onDone={reload} /> + setEditing(null)} onDone={reload} /> + setLinkGroup(null)} /> + + ); +} + +function GroupFormModal({ + open, + group, + onClose, + onDone, +}: { + open: boolean; + group: CpsGroup | null; + onClose: () => void; + onDone: () => void; +}) { + const [form] = Form.useForm(); + const isEdit = !!group; + const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || []; + const hasMeituan = watchPlatforms.includes('meituan'); + + useEffect(() => { + if (open) { + if (group) { + form.setFieldsValue({ + name: group.name, + platforms: group.platforms, + member_count: group.member_count, + remark: group.remark, + status: group.status, + }); + } else { + form.resetFields(); + form.setFieldsValue({ platforms: ['meituan'] }); + } + } + }, [open, group, form]); + + const submit = async () => { + const v = await form.validateFields(); + try { + if (isEdit && group) { + await api.patch(`/admin/api/cps/groups/${group.id}`, v); + message.success('已更新群'); + } else { + await api.post('/admin/api/cps/groups', v); + message.success('已新建群'); + } + onClose(); + onDone(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + +
+ + + + + + + {hasMeituan && !isEdit && ( + + + + )} + {hasMeituan && isEdit && ( + + 当前 sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'} + + )} + + + + {isEdit && ( + +
`共 ${t} 个活动`, + onChange, + }} + scroll={{ x: 1200 }} + /> + setCreateOpen(false)} onDone={reload} /> + setEditing(null)} onDone={reload} /> + + ); +} + +// 淘宝活动落地页图选择器:上传新图 或 点选已有图。受控组件(value = image_url 绝对 URL)。 +function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v: string) => void }) { + const [existing, setExisting] = useState([]); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + api + .get<{ images: string[] }>('/admin/api/cps/activity-images') + .then((r) => setExisting(r.data.images || [])) + .catch(() => {}); + }, []); + + const upload = async (file: File) => { + setUploading(true); + try { + const fd = new FormData(); + fd.append('file', file); + const r = await api.post<{ url: string }>('/admin/api/cps/upload-image', fd); + onChange?.(r.data.url); + setExisting((prev) => (prev.includes(r.data.url) ? prev : [r.data.url, ...prev])); + message.success('图片已上传'); + } catch (e) { + message.error(errMsg(e, '上传失败')); + } finally { + setUploading(false); + } + }; + + const thumb = (u: string, w: number, selected: boolean) => ( + 落地页图 onChange?.(u)} + style={{ + width: w, + height: Math.round(w * 1.27), + objectFit: 'cover', + borderRadius: 6, + cursor: 'pointer', + border: selected ? '2px solid #ff4d4f' : '2px solid #f0f0f0', + }} + /> + ); + + return ( +
+ { + upload(file); + return false; // 阻止 antd 默认上传,走我们自己的端点 + }} + > + + + {value && ( +
+ 当前: + {thumb(value, 90, true)} +
+ )} + {existing.length > 0 && ( +
+
或选择已有图:
+ {existing.map((u) => thumb(u, 56, u === value))} +
+ )} +
+ ); +} + +function ActivityFormModal({ + open, + activity, + onClose, + onDone, +}: { + open: boolean; + activity: CpsActivity | null; + onClose: () => void; + onDone: () => void; +}) { + const [form] = Form.useForm(); + const isEdit = !!activity; + const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan'; + + useEffect(() => { + if (open) { + if (activity) { + form.setFieldsValue({ + name: activity.name, + platform: activity.platform, + act_id: activity.act_id, + product_view_sign: activity.product_view_sign, + payload: activity.payload, + image_url: activity.image_url, + remark: activity.remark, + status: activity.status, + }); + } else { + form.resetFields(); + form.setFieldsValue({ platform: 'meituan' }); + } + } + }, [open, activity, form]); + + const submit = async () => { + const v = await form.validateFields(); + if (v.platform === 'meituan') { + if (!v.act_id && !v.product_view_sign) { + message.warning('美团活动:actId 与 productViewSign 至少填一个'); + return; + } + } else if (!v.payload) { + message.warning(v.platform === 'taobao' ? '请填淘口令' : '请填京东链接'); + return; + } + try { + if (isEdit && activity) { + await api.patch(`/admin/api/cps/activities/${activity.id}`, v); + message.success('已更新活动'); + } else { + await api.post('/admin/api/cps/activities', v); + message.success('已新建活动'); + } + onClose(); + onDone(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + + + + + + + + + + + + + )} + {platform === 'taobao' && ( + <> + + + + + + + + )} + {platform === 'jd' && ( + + + + )} + + + + + {isEdit && ( + + setSid(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> +
`共 ${t} 单`, + onChange, + }} + scroll={{ x: 1030 }} + /> + + ); +} diff --git a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx index 8b18666..ab915f8 100644 --- a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx +++ b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { + App, Button, Form, Input, @@ -12,7 +13,6 @@ import { Spin, Switch, Table, - message, } from 'antd'; import { api, errMsg } from '@/lib/api'; @@ -51,8 +51,10 @@ const yuan = (cents: number) => (cents / 100).toFixed(2); /** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */ export default function HomeMarqueeSeeds() { + const { message } = App.useApp(); const [seeds, setSeeds] = useState([]); const [loading, setLoading] = useState(true); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 批量操作选中行 const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -70,6 +72,8 @@ export default function HomeMarqueeSeeds() { try { const { data } = await api.get('/admin/api/marquee-seeds'); setSeeds(data); + // 列表刷新后清掉已不存在的选中 id(单行删除/外部变更后防悬空,顶部计数与批量操作才准) + setSelectedRowKeys((prev) => prev.filter((k) => data.some((s) => s.id === k))); } finally { setLoading(false); } @@ -175,6 +179,33 @@ export default function HomeMarqueeSeeds() { } }; + // ===== 批量操作(对选中多行)===== + const batchDelete = async () => { + try { + const { data } = await api.post<{ deleted: number }>('/admin/api/marquee-seeds/batch-delete', { + ids: selectedRowKeys, + }); + message.success(`已删除 ${data.deleted} 条`); + setSelectedRowKeys([]); + load(); + } catch (e) { + message.error(errMsg(e)); + } + }; + const batchSetEnabled = async (enabled: boolean) => { + try { + const { data } = await api.post<{ updated: number }>('/admin/api/marquee-seeds/batch-enable', { + ids: selectedRowKeys, + enabled, + }); + message.success(`已${enabled ? '启用' : '停用'} ${data.updated} 条`); + setSelectedRowKeys([]); + load(); + } catch (e) { + message.error(errMsg(e)); + } + }; + const columns = [ { title: '用户名', @@ -229,7 +260,7 @@ export default function HomeMarqueeSeeds() { 首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。 用户名留空则每次展示随机合成;金额为区间则每次随机取值。

- + @@ -239,11 +270,34 @@ export default function HomeMarqueeSeeds() { + + + + + + + {selectedRowKeys.length > 0 && ( + 已选 {selectedRowKeys.length} 条 + )}
setSelectedRowKeys(keys.map(Number)), + }} columns={columns} dataSource={seeds} pagination={false} diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/dashboard/HomeStatsConfig.tsx index 0b446b2..326920a 100644 --- a/src/app/(main)/dashboard/HomeStatsConfig.tsx +++ b/src/app/(main)/dashboard/HomeStatsConfig.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { + App, Button, Card, Checkbox, @@ -15,9 +16,9 @@ import { Spin, Tag, Tooltip, - message, } from 'antd'; import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; interface StatItem { metric: string; // help_users / total_compares / total_saved @@ -32,7 +33,7 @@ interface StatItem { random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量 random_step_min: number; // 绝对增量区间(基础单位) random_step_max: number; - real_offset: number; // 真实值基数偏移(基础单位) + real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改) allow_decrease: boolean; // 是否允许展示值下降 random_current: number | null; random_last_tick_at: string | null; @@ -54,7 +55,7 @@ function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } { interface Edit { mode: 'real' | 'manual' | 'random'; manual: number | null; // 展示单位 - realOffset: number; // 真实值偏移(展示单位) + realOffset: number; // 真实值保底值(展示单位) growthKind: 'mult' | 'add'; // 自增长方式 multMin: number; // 1.0 ~ multMax: number; @@ -127,7 +128,7 @@ function predictNext(it: StatItem): string | null { const hi = Math.floor((cur * it.random_mult_max) / 1000); return `${d(lo)} ~ ${d(hi)} ${u}`; } - const target = it.mode === 'real' ? it.real_value + it.real_offset : it.manual_value ?? cur; + const target = it.mode === 'real' ? Math.max(it.real_value, it.real_offset) : it.manual_value ?? cur; const eff = !it.allow_decrease && target < cur ? cur : target; return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`; } @@ -150,8 +151,104 @@ function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string { }); } +// ===== 三者关系「智能校验」(劝阻式,非硬拦)===== +// 触发阈值比「理想区间」宽,只在明显离谱时弹窗;理想区间在弹窗里作为建议展示。 +const REL_PER_USER = { min: 2, max: 20, ideal: '4~8 次/人' }; // 人均比价 = 完成比价 / 帮助用户 +const REL_PER_COMPARE = { min: 2, max: 30, ideal: '5~12 元/次' }; // 每次省额 = 累计节省(元) / 完成比价 + +function fmtNum(n: number): string { + return n >= 10000 ? `${(n / 10000).toFixed(2).replace(/\.?0+$/, '')}万` : String(Math.round(n)); +} + +// 只增不减护栏的前端镜像:目标低于现值且未允许下降时,实际仍显示现值。 +function guardedBase(allowDecrease: boolean, current: number | null, target: number | null): number | null { + if (target == null) return current; + if (!allowDecrease && current != null && target < current) return current; + return target; +} + +// 本次保存后,该指标「大概会显示」的值(基础单位:total_saved 为分)。 +function intendedBase(it: StatItem, e: Edit): number | null { + const cur = it.random_current; + if (e.mode === 'manual') { + const t = e.manual != null ? (dispToBase(it.metric, e.manual) as number) : cur; + return guardedBase(e.allowDecrease, cur, t); + } + if (e.mode === 'real') { + const t = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0); + return guardedBase(e.allowDecrease, cur, t); + } + // random:填了初始基数=设起点(过护栏);留空=保持现展示值 + if (e.initial != null) return guardedBase(e.allowDecrease, cur, dispToBase(it.metric, e.initial) as number); + return cur; +} + +// 校验三者关系。返回 null=数据不全无法判断(跳过校验);否则 warnings 为空=合理。 +// 被改的指标用「本次要设的目标值」,另两个用各自当前展示值 random_current。 +function checkRelations( + items: StatItem[], + edits: Record, +): { warnings: string[]; users: number; compares: number; savedYuan: number } | null { + const valBase = (metric: string): number | null => { + const it = items.find((x) => x.metric === metric); + const e = it ? edits[metric] : undefined; + return it && e ? intendedBase(it, e) : null; + }; + const users = valBase('help_users'); + const compares = valBase('total_compares'); + const savedBase = valBase('total_saved'); + if (users == null || compares == null || savedBase == null) return null; + if (users <= 0 || compares <= 0 || savedBase <= 0) return null; + + const savedYuan = savedBase / 100; + const warnings: string[] = []; + if (compares < users) { + warnings.push(`完成比价(${fmtNum(compares)})少于帮助用户(${fmtNum(users)}),正常应「次数 ≥ 人数」`); + } + const perUser = compares / users; + if (perUser < REL_PER_USER.min || perUser > REL_PER_USER.max) { + warnings.push( + `人均比价 ${perUser.toFixed(1)} 次/人,${perUser < REL_PER_USER.min ? '偏低' : '偏高'}(建议 ${REL_PER_USER.ideal})`, + ); + } + const perCompare = savedYuan / compares; + if (perCompare < REL_PER_COMPARE.min || perCompare > REL_PER_COMPARE.max) { + warnings.push( + `每次省 ${perCompare.toFixed(1)} 元/次,${perCompare < REL_PER_COMPARE.min ? '偏低' : '偏高'}(建议 ${REL_PER_COMPARE.ideal})`, + ); + } + return { warnings, users, compares, savedYuan }; +} + +// 把某指标的编辑态打包成 PATCH body(三项共用,逐个指标各打一份)。 +function buildBody(metric: string, e: Edit, immediate: boolean): Record { + // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。 + const intervalSec = + e.mode === 'manual' + ? 86400 + : Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]); + const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位 + const body: Record = { + mode: e.mode, + random_mult_min: Math.round(e.multMin * 1000), + random_mult_max: Math.round(e.multMax * 1000), + random_tick_seconds: intervalSec, + random_anchor_minutes: anchorMin, + random_kind: e.growthKind, + random_step_min: (dispToBase(metric, e.stepMin) as number) ?? 0, + random_step_max: (dispToBase(metric, e.stepMax) as number) ?? 0, + real_offset: (dispToBase(metric, e.realOffset) as number) ?? 0, + allow_decrease: e.allowDecrease, + }; + if (e.manual != null) body.manual_value = dispToBase(metric, e.manual) as number; + if (e.initial != null) body.random_initial = dispToBase(metric, e.initial) as number; + if (immediate) body.apply_now = true; + return body; +} + /** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */ export default function HomeStatsConfig() { + const { message, modal } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [edits, setEdits] = useState>({}); @@ -188,52 +285,67 @@ export default function HomeStatsConfig() { const patch = (metric: string, p: Partial) => setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } })); - // immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。 - const save = async (it: StatItem, immediate = false) => { - const e = edits[it.metric]; - // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。 - const intervalSec = - e.mode === 'manual' - ? 86400 - : Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]); - const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位 - const body: Record = { - mode: e.mode, - random_mult_min: Math.round(e.multMin * 1000), - random_mult_max: Math.round(e.multMax * 1000), - random_tick_seconds: intervalSec, - random_anchor_minutes: anchorMin, - random_kind: e.growthKind, - random_step_min: (dispToBase(it.metric, e.stepMin) as number) ?? 0, - random_step_max: (dispToBase(it.metric, e.stepMax) as number) ?? 0, - real_offset: (dispToBase(it.metric, e.realOffset) as number) ?? 0, - allow_decrease: e.allowDecrease, - }; - if (e.manual != null) body.manual_value = dispToBase(it.metric, e.manual) as number; - if (e.initial != null) body.random_initial = dispToBase(it.metric, e.initial) as number; - if (immediate) body.apply_now = true; - - setSaving(it.metric); - try { - // PATCH 返回更新后的该指标;只就地替换这一项 + 重置它的输入态,不整页 reload(消除闪烁)。 - const { data } = await api.patch(`/admin/api/dashboard-display/${it.metric}`, body); - setItems((prev) => prev.map((x) => (x.metric === it.metric ? data : x))); - setEdits((s) => ({ ...s, [it.metric]: toEdit(data) })); - if (immediate) { - message.success('已立即更新,客户端下次进首页即可见'); - } else { - const lastTickMs = data.random_last_tick_at ? new Date(data.random_last_tick_at).getTime() : 0; - const effectiveNow = Date.now() - lastTickMs < 10_000; + // 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。 + // 校验三者最终关系,不合理则弹窗劝阻、确认才提交。 + const saveAll = async (immediate = false) => { + const proceed = async () => { + setSaving('__all__'); + // 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。 + const results: StatItem[] = []; + try { + for (const it of items) { + const { data } = await api.patch( + `/admin/api/dashboard-display/${it.metric}`, + buildBody(it.metric, edits[it.metric], immediate), + ); + results.push(data); + } message.success( - effectiveNow - ? '已保存,客户端下次进首页即可见' - : `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`, + immediate + ? '三项已立即更新,客户端下次进首页即可见' + : '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)', ); + } catch (err) { + // 前 results.length 项后端已 commit,失败的是第 results.length+1 项 + message.error(`第 ${results.length + 1} 项保存失败(前 ${results.length} 项已成功): ${errMsg(err)}`); + } finally { + // 半失败也把已成功的就地合并进 state,避免前端显旧值与后端撕裂 + if (results.length > 0) { + setItems((prev) => prev.map((x) => results.find((r) => r.metric === x.metric) ?? x)); + setEdits((s) => ({ ...s, ...Object.fromEntries(results.map((d) => [d.metric, toEdit(d)])) })); + } + setSaving(null); } - } catch (err) { - message.error(errMsg(err)); - } finally { - setSaving(null); + }; + + // 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。 + const rel = checkRelations(items, edits); + if (rel && rel.warnings.length > 0) { + modal.confirm({ + title: '数据关系校验', + width: 480, + okText: '仍要这样设置', + cancelText: '返回修改', + content: ( +
+
检测到三项门面数据关系不太真实:
+
    + {rel.warnings.map((w, i) => ( +
  • + {w} +
  • + ))} +
+
+ 当前预计:帮助用户 {fmtNum(rel.users)} · 完成比价 {fmtNum(rel.compares)} · 累计节省{' '} + {fmtNum(rel.savedYuan)}元 +
+
+ ), + onOk: proceed, + }); + } else { + await proceed(); } }; @@ -241,7 +353,7 @@ export default function HomeStatsConfig() {

首页数据配置

- 客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可加基数偏移)/{' '} + 客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可设保底值)/{' '} 自定义(固定值)/ 自增长(到对齐钟点按倍率或固定增量增长)。 默认只增不减(防门面缩水);每次改动进审计日志。

@@ -260,6 +372,20 @@ export default function HomeStatsConfig() { e.manual != null && it.random_current != null && (dispToBase(it.metric, e.manual) as number) < it.random_current; + // real 同理:max(真实值,保底值) 低于当前展示值且未允许下降 → 立即更新会被只增不减护栏挡住 + const realTargetBase = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0); + const realBlocked = + e.mode === 'real' && + !e.allowDecrease && + it.random_current != null && + realTargetBase < it.random_current; + // 自增长:填的初始基数低于当前展示值且未允许下降 → 会被只增不减护栏挡住(保持现值) + const initialBlocked = + e.mode === 'random' && + !e.allowDecrease && + e.initial != null && + it.random_current != null && + (dispToBase(it.metric, e.initial) as number) < it.random_current; return (
- 基数偏移: - patch(it.metric, { realOffset: v ?? 0 })} - style={{ width: 160 }} - /> - {u} - - - - +
+ + 保底值: + patch(it.metric, { realOffset: v ?? 0 })} + style={{ width: 160 }} + /> + {u} + + + + + {realBlocked && ( +
+ 真实值与保底取大后仍低于当前展示值,默认不下调(如需下调请勾「允许数字下降」) +
+ )} +
)} {e.mode === 'random' && ( @@ -431,17 +564,26 @@ export default function HomeStatsConfig() { {e.mode === 'random' && ( - - 初始基数: - patch(it.metric, { initial: v })} - style={{ width: 200 }} - placeholder="留空=用真实值播种" - /> - {u} - +
+ + 初始基数: + patch(it.metric, { initial: v })} + style={{ width: 200 }} + /> + {u} + + + + + {initialBlocked && ( +
+ 低于当前展示值,默认不下调(如需下调请勾「允许数字下降」) +
+ )} +
)} {it.updated_at && ( - 更新于 {new Date(it.updated_at).toLocaleString('zh-CN')} + 更新于 {formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')} )} @@ -474,21 +616,10 @@ export default function HomeStatsConfig() { )} - - - save(it, true)} - > - - - - 线上当前: - {it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'} - - + + 线上当前: + {it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'} +
@@ -496,6 +627,22 @@ export default function HomeStatsConfig() { })} )} + {!loading && ( +
+ + + saveAll(true)} + > + + + 三项配置共用,一次保存全部生效 + +
+ )} ); } diff --git a/src/app/(main)/devices/page.tsx b/src/app/(main)/devices/page.tsx new file mode 100644 index 0000000..99c03b7 --- /dev/null +++ b/src/app/(main)/devices/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import type { ColumnsType } from 'antd/es/table'; +import { App, Button, Popconfirm, Space, Table } from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; +import { useCursorList } from '@/lib/useCursorList'; +import type { DeviceOnboardingItem } from '@/lib/types'; + +// completed_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string) => formatUtcTime(v); +const EMPTY: Record = {}; + +export default function DevicesPage() { + const { message, modal } = App.useApp(); + // 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。 + const { items, loading, reload } = useCursorList( + '/admin/api/onboarding/devices', + EMPTY, + ); + const canReset = canDo(['operator']); + + const resetDevice = async (d: DeviceOnboardingItem) => { + try { + await api.post(`/admin/api/onboarding/devices/${encodeURIComponent(d.device_id)}/reset`); + message.success('已重置:该设备所有账号下次打开 App 重走引导'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + const resetAll = () => { + modal.confirm({ + title: '确认全部重设新手引导?', + content: + '清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。', + okText: '确认全部重设', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await api.post('/admin/api/onboarding/reset-all', { confirm: true }); + message.success('已全部重设:所有用户下次打开 App 会重走引导'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + + const columns: ColumnsType = [ + { title: '设备(ANDROID_ID)', dataIndex: 'device_id' }, + { title: '走过引导的账号数', dataIndex: 'account_count', width: 160 }, + { title: '最近完成引导', dataIndex: 'last_completed_at', render: dt, width: 200 }, + { + title: '操作', + key: 'op', + width: 120, + render: (_: unknown, d: DeviceOnboardingItem) => + canReset ? ( + resetDevice(d)} + okText="确认" + cancelText="取消" + > + 重走引导 + + ) : ( + - + ), + }, + ]; + + return ( +
+

设备管理

+

+ 列出走过新手引导的设备(按硬件 ANDROID_ID 聚合)。「重走引导」删除该设备的引导完成记录, + 使其上所有账号下次打开 App 重走一遍。没走过引导的设备本就会引导,不在此列。 +

+ {canReset && ( + + + + )} +
+ + ); +} diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index e1cadf6..e7462f1 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -1,25 +1,99 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { Key } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Image, Select, Space, Table, Tag, message } from 'antd'; +import type { SorterResult } from 'antd/es/table/interface'; +import { + App, + Button, + DatePicker, + Image, + Input, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd'; +import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; -import { mediaUrl } from '@/lib/media'; -import { useCursorList } from '@/lib/useCursorList'; +import { formatUtcTime } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; import type { Feedback } from '@/lib/types'; import FeedbackQrConfig from './FeedbackQrConfig'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +const { Text, Paragraph } = Typography; +const { RangePicker } = DatePicker; + +// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 +const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || ''; +const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p); + +type SortField = 'id' | 'created_at'; export default function FeedbacksPage() { + const { message, modal } = App.useApp(); + // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/feedbacks', - filters, - ); + const [userId, setUserId] = useState(''); + const [content, setContent] = useState(''); + const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/feedbacks', filters); + const canHandle = canDo(['operator']); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 筛选/排序变化后清空已选(避免选中项跨筛选错位) + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey]); + + const search = () => + setApplied({ + status, + user_id: userId.trim() ? Number(userId.trim()) : undefined, + content: content.trim() || undefined, + created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined, + created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setStatus(undefined); + setUserId(''); + setContent(''); + setCreatedRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); + }; + + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + setSortBy('id'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; const handle = async (f: Feedback) => { try { @@ -31,14 +105,56 @@ export default function FeedbacksPage() { } }; + const selectedNew = items.filter( + (f) => selectedRowKeys.includes(f.id) && f.status === 'new', + ); + + // 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 + const bulkHandle = () => { + if (!selectedNew.length) { + message.warning('没有可处理的选中项(仅待处理 new 态生效)'); + return; + } + modal.confirm({ + title: `确认批量标记处理 ${selectedNew.length} 条反馈?`, + content: '仅对选中的待处理(new)反馈生效', + onOk: async () => { + const results = await Promise.allSettled( + selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)), + ); + const ok = results.filter((r) => r.status === 'fulfilled').length; + const fail = results.length - ok; + if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`); + else message.success(`批量标记处理完成:${ok} 条`); + setSelectedRowKeys([]); + reload(); + }, + }); + }; + const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, + { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, - { title: '内容', dataIndex: 'content' }, + { + title: '内容', + dataIndex: 'content', + width: 360, + render: (v: string) => + v ? ( + + {v} + + ) : ( + - + ), + }, { title: '截图', dataIndex: 'images', - width: 150, + width: 180, render: (imgs: string[] | null) => imgs && imgs.length ? ( @@ -47,25 +163,32 @@ export default function FeedbacksPage() { ))} ) : ( - + ), }, - { title: '联系方式', dataIndex: 'contact', width: 140 }, + { title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s}, }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 180 }, + { + title: '时间', + dataIndex: 'created_at', + width: 180, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, { title: '操作', key: 'op', @@ -83,28 +206,82 @@ export default function FeedbacksPage() {

反馈工单

- +
-
- -
+ + + + {canHandle && selectedNew.length > 0 && ( + + 已选 {selectedNew.length} 条待处理 + + + + )} + +
({ disabled: f.status !== 'new' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条反馈`, + onChange: onPageChange, + }} + onChange={onTableChange} + /> ); } diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 8672cc2..c816b06 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -3,14 +3,17 @@ import { useEffect, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { + BarChartOutlined, DashboardOutlined, FileSearchOutlined, FlagOutlined, - FundProjectionScreenOutlined, LogoutOutlined, MessageOutlined, + MobileOutlined, MoneyCollectOutlined, + ProfileOutlined, SettingOutlined, + ShareAltOutlined, TeamOutlined, UserOutlined, } from '@ant-design/icons'; @@ -23,10 +26,13 @@ const { Sider, Header, Content } = Layout; const MENU = [ { key: '/dashboard', icon: , label: '数据大盘' }, { key: '/users', icon: , label: '用户管理' }, + { key: '/devices', icon: , label: '设备管理' }, { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, + { key: '/comparison-records', icon: , label: '比价记录' }, { key: '/feedbacks', icon: , label: '反馈工单' }, - { key: '/ad-audit', icon: , label: '金币审计' }, + { key: '/ad-revenue', icon: , label: '广告管理' }, + { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/app/(main)/price-reports/page.tsx b/src/app/(main)/price-reports/page.tsx index e804397..d5dff1e 100644 --- a/src/app/(main)/price-reports/page.tsx +++ b/src/app/(main)/price-reports/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { + App, Button, Card, Image, @@ -13,7 +14,6 @@ import { Tabs, Tag, Typography, - message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; @@ -21,7 +21,7 @@ import dayjs from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { mediaUrl } from '@/lib/media'; -import { useCursorList } from '@/lib/useCursorList'; +import { usePagedList } from '@/lib/usePagedList'; import type { PriceReport, PriceReportSummary } from '@/lib/types'; const { Text } = Typography; @@ -56,6 +56,7 @@ function statusTag(status: string) { } export default function PriceReportsPage() { + const { message, modal } = App.useApp(); const [activeStatus, setActiveStatus] = useState('pending'); const [summary, setSummary] = useState(null); const [rejecting, setRejecting] = useState(null); @@ -64,10 +65,8 @@ export default function PriceReportsPage() { const filters: Record = {}; if (activeStatus !== 'all') filters.status = activeStatus; - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/price-reports', - filters, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/price-reports', filters); const canReview = canDo(['operator']); @@ -89,7 +88,7 @@ export default function PriceReportsPage() { }; const approve = (r: PriceReport) => { - Modal.confirm({ + modal.confirm({ title: '确认通过该上报?', icon: , content: ( @@ -276,14 +275,16 @@ export default function PriceReportsPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: onPageChange, + }} scroll={{ x: 1100 }} /> -
- -
`¥${(c / 100).toFixed(2)}`; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; +import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types'; export default function UserDetailPage() { + const { message, modal } = App.useApp(); const params = useParams<{ id: string }>(); const uid = Number(params.id); const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [coinOpen, setCoinOpen] = useState(false); + const [cashOpen, setCashOpen] = useState(false); - useEffect(() => { - api.get(`/admin/api/users/${uid}`).then((r) => setData(r.data)); + const loadData = useCallback(() => { + setErr(null); + api + .get(`/admin/api/users/${uid}`) + .then((r) => setData(r.data)) + .catch((e) => setErr(errMsg(e, '加载用户详情失败'))); }, [uid]); + useEffect(() => { + loadData(); + }, [loadData]); + const coins = useCursorList('/admin/api/wallet/coin-transactions', { user_id: uid }); + const cash = useCursorList('/admin/api/wallet/cash-transactions', { user_id: uid }); const withdraws = useCursorList('/admin/api/withdraws', { user_id: uid }); + const canCoins = canDo(['finance']); + const canStatus = canDo(['operator']); + + const toggleStatus = () => { + if (!data) return; + const next = data.user.status === 'active' ? 'disabled' : 'active'; + modal.confirm({ + title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`, + onOk: async () => { + try { + await api.post(`/admin/api/users/${uid}/status`, { status: next }); + message.success('已更新状态'); + loadData(); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + + if (err) { + return ( + + 重试 + + } + /> + ); + } if (!data) return ; const coinCols: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: dt }, + // 金币流水为北京 wall-clock 口径,原样显示 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) }, { title: '变动', dataIndex: 'amount', @@ -42,17 +104,58 @@ export default function UserDetailPage() { { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, ]; + const cashCols: ColumnsType = [ + // 现金流水为北京 wall-clock 口径,原样显示 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) }, + { + title: '变动', + dataIndex: 'amount_cents', + render: (v: number) => ( + 0 ? '#3f8600' : '#cf1322' }}> + {v > 0 ? '+' : ''} + {yuan(v)} + + ), + }, + { title: '余额', dataIndex: 'balance_after_cents', render: yuan }, + { title: '类型', dataIndex: 'biz_type' }, + { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, + ]; + const wdCols: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: dt }, + // 提现单为 UTC 口径 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) }, { title: '金额', dataIndex: 'amount_cents', render: yuan }, { title: '状态', dataIndex: 'status', render: (s: string) => {s} }, { title: '单号', dataIndex: 'out_bill_no' }, { title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' }, ]; + const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => ( + + ); + return (
-

用户详情 #{uid}

+ +

用户详情 #{uid}

+ + {canCoins && } + {canCoins && } + {canStatus && data.user.status !== 'deleted' && ( + + )} + +
{data.user.phone} @@ -64,7 +167,7 @@ export default function UserDetailPage() { {data.user.wechat_openid ? '已绑定' : '未绑定'} - {dt(data.user.created_at)} + {formatUtcTime(data.user.created_at)} @@ -115,11 +218,23 @@ export default function UserDetailPage() { loading={coins.loading} pagination={false} /> - + {loadMoreLink(coins)} + + ), + }, + { + key: 'cash', + label: '现金流水', + children: ( + <> +
+ {loadMoreLink(cash)} ), }, @@ -138,6 +253,25 @@ export default function UserDetailPage() { }, ]} /> + + setCoinOpen(false)} + onDone={() => { + loadData(); + coins.reload(); + }} + /> + setCashOpen(false)} + onDone={() => { + loadData(); + cash.reload(); + }} + /> ); } diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 1e4bc36..d53269b 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -1,60 +1,104 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { Key } from 'react'; import { useRouter } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; -import { - Button, - Form, - Input, - InputNumber, - Modal, - Select, - Space, - Table, - Tag, - message, -} from 'antd'; +import type { SorterResult } from 'antd/es/table/interface'; +import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd'; +import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; -import { useCursorList } from '@/lib/useCursorList'; +import { formatUtcTime } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; +import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; import type { UserListItem } from '@/lib/types'; +const { RangePicker } = DatePicker; + const STATUS_COLOR: Record = { active: 'green', disabled: 'red', deleted: 'default' }; +type SortField = 'id' | 'created_at' | 'last_login_at'; + export default function UsersPage() { const router = useRouter(); + const { message, modal } = App.useApp(); + + // 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交 const [phone, setPhone] = useState(''); + const [nickname, setNickname] = useState(''); const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/users', - filters, - ); + const [channel, setChannel] = useState(); + const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null); + const [loginRange, setLoginRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/users', filters); const canCoins = canDo(['finance']); const canStatus = canDo(['operator']); const [coinUser, setCoinUser] = useState(null); - const [coinForm] = Form.useForm(); + const [cashUser, setCashUser] = useState(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const search = () => setFilters({ phone: phone || undefined, status }); + // 筛选/排序变化后清空已选(避免选中项跨筛选错位) + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey]); - const submitCoins = async () => { - const v = await coinForm.validateFields(); - try { - await api.post(`/admin/api/users/${coinUser!.id}/coins`, v); - message.success('已调整金币'); - setCoinUser(null); - coinForm.resetFields(); - } catch (e) { - message.error(errMsg(e)); + const search = () => + setApplied({ + phone: phone || undefined, + nickname: nickname || undefined, + status, + register_channel: channel || undefined, + created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined, + created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined, + last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined, + last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setPhone(''); + setNickname(''); + setStatus(undefined); + setChannel(undefined); + setRegRange(null); + setLoginRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); + }; + + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + // 取消排序 → 回默认(ID 倒序) + setSortBy('id'); + setSortOrder('desc'); } }; + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + const toggleStatus = (u: UserListItem) => { const next = u.status === 'active' ? 'disabled' : 'active'; - Modal.confirm({ + modal.confirm({ title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`, onOk: async () => { try { @@ -70,7 +114,7 @@ export default function UsersPage() { const toggleDebugTrace = (u: UserListItem) => { const next = !u.debug_trace_enabled; - Modal.confirm({ + modal.confirm({ title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`, content: next ? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障' @@ -90,7 +134,7 @@ export default function UsersPage() { // 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。 // 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。 const setForceOnboarding = (u: UserListItem, enable: boolean) => { - Modal.confirm({ + modal.confirm({ title: enable ? `确认让用户 ${u.phone} 重看一次新手引导?` : `撤销用户 ${u.phone} 的「重看新手引导」?`, @@ -109,10 +153,72 @@ export default function UsersPage() { }); }; + const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id)); + const selectedActive = selectedUsers.filter((u) => u.status === 'active'); + const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled'); + + // 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 + const runBulk = async ( + targets: UserListItem[], + label: string, + call: (u: UserListItem) => Promise, + ) => { + const results = await Promise.allSettled(targets.map(call)); + const ok = results.filter((r) => r.status === 'fulfilled').length; + const fail = results.length - ok; + if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`); + else message.success(`${label}完成:${ok} 个`); + setSelectedRowKeys([]); + reload(); + }; + + const bulkStatus = (target: 'disabled' | 'active') => { + const targets = target === 'disabled' ? selectedActive : selectedDisabled; + const label = target === 'disabled' ? '批量封禁' : '批量解封'; + if (!targets.length) { + message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`); + return; + } + modal.confirm({ + title: `确认${label} ${targets.length} 个用户?`, + content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效', + okButtonProps: { danger: target === 'disabled' }, + onOk: () => + runBulk(targets, label, (u) => + api.post(`/admin/api/users/${u.id}/status`, { status: target }), + ), + }); + }; + + const bulkDebugTrace = (enabled: boolean) => { + // 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求 + const targets = selectedUsers.filter( + (u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled, + ); + const label = enabled ? '批量开调试链接' : '批量关调试链接'; + if (!targets.length) { + message.warning('没有需要变更的选中用户'); + return; + } + modal.confirm({ + title: `确认${label} ${targets.length} 个用户?`, + onOk: () => + runBulk(targets, label, (u) => + api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }), + ), + }); + }; + const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '手机号', dataIndex: 'phone' }, - { title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' }, + { title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') }, + { title: '手机号', dataIndex: 'phone', width: 130 }, + { + title: '昵称', + dataIndex: 'nickname', + width: 150, + ellipsis: true, + render: (v: string | null) => v || '-', + }, { title: '渠道', dataIndex: 'register_channel', width: 90 }, { title: '状态', @@ -123,15 +229,29 @@ export default function UsersPage() { { title: '注册时间', dataIndex: 'created_at', - render: (v: string) => new Date(v).toLocaleString('zh-CN'), + width: 160, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, + { + title: '最近登录', + dataIndex: 'last_login_at', + width: 160, + sorter: true, + sortOrder: sortOrderOf('last_login_at'), + render: (v: string) => formatUtcTime(v), }, { title: '操作', key: 'op', + fixed: 'right', + width: 300, render: (_: unknown, u: UserListItem) => ( - + e.stopPropagation()}> router.push(`/users/${u.id}`)}>详情 {canCoins && setCoinUser(u)}>调金币} + {canCoins && setCashUser(u)}>调现金} {canStatus && u.status !== 'deleted' && ( toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'} )} @@ -164,47 +284,105 @@ export default function UsersPage() { onChange={(e) => setPhone(e.target.value)} onPressEnter={search} allowClear - style={{ width: 160 }} + style={{ width: 150 }} + /> + setNickname(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} /> + setRegRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + setLoginRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + -
-
- -
- setCoinUser(null)} - destroyOnClose - > - - - - - - - - - + {canStatus && selectedUsers.length > 0 && ( + + + 已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '} + {selectedDisabled.length}) + + + + + + + + )} + +
({ disabled: u.status === 'deleted' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 个用户`, + onChange: onPageChange, + }} + scroll={{ x: 1160 }} + onChange={onTableChange} + /> + + setCoinUser(null)} /> + setCashUser(null)} /> ); } diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index 57213cb..3c1dd62 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { Key } from 'react'; import { + App, Alert, Button, Card, @@ -22,7 +23,6 @@ import { Tag, Timeline, Typography, - message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { @@ -36,11 +36,10 @@ import { SyncOutlined, } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; -import 'dayjs/locale/zh-cn'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; -import { useCursorList } from '@/lib/useCursorList'; +import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog, WithdrawBulkResult, @@ -53,19 +52,12 @@ import type { } from '@/lib/types'; import UserRewardPanel from './UserRewardPanel'; -dayjs.extend(relativeTime); -dayjs.locale('zh-cn'); - const { Text } = Typography; const { RangePicker } = DatePicker; -const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`; -const apiTime = (v: string) => { - const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); - return dayjs(hasTimezone ? v : `${v}Z`); -}; -const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm'); -const ago = (v: string) => apiTime(v).fromNow(); +// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。 +const dt = (v: string) => formatUtcTime(v); +const ago = (v: string) => utcFromNow(v); const STATUS_COLOR: Record = { reviewing: 'gold', @@ -155,7 +147,7 @@ function riskTags(detail: WithdrawDetail | null) { const tags: string[] = []; if (!detail.order.user_name) tags.push('缺少提现实名'); if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`); - if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户'); + if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户'); if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户'); if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) { tags.push('历史异常单'); @@ -171,10 +163,12 @@ function bulkResultText(action: string, result: WithdrawBulkResult) { } export default function WithdrawsPage() { + const { message, modal } = App.useApp(); const [activeStatus, setActiveStatus] = useState('reviewing'); const [summary, setSummary] = useState(null); const [health, setHealth] = useState(null); const [ledger, setLedger] = useState(null); + const [ledgerLoading, setLedgerLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); @@ -182,7 +176,6 @@ export default function WithdrawsPage() { const [bulkRejecting, setBulkRejecting] = useState([]); const [rejectReason, setRejectReason] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [pageSize, setPageSize] = useState(20); const [searchDraft, setSearchDraft] = useState(''); const [keyword, setKeyword] = useState(''); const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); @@ -201,11 +194,8 @@ export default function WithdrawsPage() { filters.sort_by = sortBy; filters.sort_order = sortOrder; const filterKey = JSON.stringify(filters); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/withdraws', - filters, - pageSize, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/withdraws', filters, 20); const canManage = canDo(['finance']); const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id)); const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing'); @@ -247,19 +237,28 @@ export default function WithdrawsPage() { } }, []); - const loadChecks = useCallback(async () => { + const loadHealth = useCallback(async () => { try { - const [{ data: healthData }, { data: ledgerData }] = await Promise.all([ - api.get('/admin/api/withdraws/health-check'), - api.get('/admin/api/withdraws/ledger-check'), - ]); - setHealth(healthData); - setLedger(ledgerData); + const { data } = await api.get('/admin/api/withdraws/health-check'); + setHealth(data); } catch (e) { message.error(errMsg(e)); } }, []); + // 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。 + const loadLedger = useCallback(async () => { + setLedgerLoading(true); + try { + const { data } = await api.get('/admin/api/withdraws/ledger-check'); + setLedger(data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLedgerLoading(false); + } + }, []); + const loadDetail = useCallback(async (outBillNo: string) => { setDetailLoading(true); try { @@ -274,17 +273,19 @@ export default function WithdrawsPage() { useEffect(() => { void loadSummary(); - void loadChecks(); - }, [loadChecks, loadSummary]); + void loadHealth(); + void loadLedger(); + }, [loadHealth, loadLedger, loadSummary]); useEffect(() => { setSelectedRowKeys([]); - }, [filterKey, pageSize]); + }, [filterKey, page, pageSize]); const refreshAfterChange = async (outBillNo?: string) => { setSelectedRowKeys([]); reload(); - await Promise.all([loadSummary(), loadChecks()]); + // 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。 + await Promise.all([loadSummary(), loadHealth()]); if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) { await loadDetail(outBillNo); } @@ -297,7 +298,7 @@ export default function WithdrawsPage() { }; const approve = (o: WithdrawOrder) => { - Modal.confirm({ + modal.confirm({ title: `确认通过并打款 ${yuan(o.amount_cents)}?`, content: (
@@ -360,7 +361,7 @@ export default function WithdrawsPage() { } const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0); let confirmText = ''; - Modal.confirm({ + modal.confirm({ title: `确认批量通过 ${selectedReviewing.length} 笔提现?`, content: ( @@ -398,7 +399,7 @@ export default function WithdrawsPage() { message.warning('请选择打款中的提现单'); return; } - Modal.confirm({ + modal.confirm({ title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`, content: '会逐笔向微信查单,并按最新状态归一化。', okText: '批量刷新查单', @@ -420,7 +421,7 @@ export default function WithdrawsPage() { }; const retry = (o: WithdrawOrder) => { - Modal.confirm({ + modal.confirm({ title: `重新查询 ${o.out_bill_no} 的微信状态?`, content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。', okText: '刷新查单', @@ -442,7 +443,7 @@ export default function WithdrawsPage() { }; const reconcile = () => { - Modal.confirm({ + modal.confirm({ title: '批量对账?', content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。', okText: '开始对账', @@ -648,6 +649,16 @@ export default function WithdrawsPage() { ? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}` : ledgerIssues.join(';') } + action={ + + } /> )} @@ -812,31 +823,6 @@ export default function WithdrawsPage() { )} - - - 当前已加载 {items.length} 条{nextCursor ? ',还有更多' : ',已到最后一页'} - - - 每次加载 -
`共 ${t} 条`, + onChange: onPageChange, + }} scroll={{ x: 1350 }} onRow={(record) => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' }, })} /> -
- -
{ diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a5493c3..e81d986 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,15 +5,27 @@ // 必须在引入 antd 之前 import。 import '@ant-design/v5-patch-for-react-19'; import { AntdRegistry } from '@ant-design/nextjs-registry'; -import { ConfigProvider } from 'antd'; +import { App, ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; export function Providers({ children }: { children: React.ReactNode }) { return ( {/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */} - - {children} + {/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */} + + {/* antd 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider + 的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */} + {children} ); diff --git a/src/components/AdjustBalanceModals.tsx b/src/components/AdjustBalanceModals.tsx new file mode 100644 index 0000000..8c287b7 --- /dev/null +++ b/src/components/AdjustBalanceModals.tsx @@ -0,0 +1,203 @@ +'use client'; + +// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。 +// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。 +import { useEffect, useState } from 'react'; +import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { yuan } from '@/lib/format'; +import type { UserOverview } from '@/lib/types'; + +export type AdjustTargetUser = { id: number; phone: string }; + +interface ModalProps { + user: AdjustTargetUser | null; + open: boolean; + onClose: () => void; + onDone?: () => void; // 调整成功后回调(刷新余额/列表) +} + +const balanceLine = (balances: { coin: number; cashCents: number } | null) => ( +

+ 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} +

+); + +// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。 +function useBalances(userId: number | undefined, open: boolean) { + const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null); + useEffect(() => { + if (!open || userId == null) { + setBalances(null); + return; + } + let alive = true; + setBalances(null); + api + .get(`/admin/api/users/${userId}`) + .then((r) => { + if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [userId, open]); + return balances; +} + +export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [mode, setMode] = useState<'delta' | 'set'>('delta'); + const balances = useBalances(user?.id, open); + + useEffect(() => { + if (open) { + setMode('delta'); + form.resetFields(); + } + }, [open, form]); + + const submit = async () => { + if (!user) return; + const v = await form.validateFields(); + try { + await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v }); + message.success(mode === 'set' ? '已设置金币' : '已调整金币'); + onClose(); + onDone?.(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} + +export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [mode, setMode] = useState<'delta' | 'set'>('delta'); + const balances = useBalances(user?.id, open); + + useEffect(() => { + if (open) { + setMode('delta'); + form.resetFields(); + } + }, [open, form]); + + // 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现) + const submit = async () => { + if (!user) return; + const v = await form.validateFields(); + const amountCents = Math.round(Number(v.amount_yuan) * 100); + if (mode === 'delta' && amountCents === 0) { + message.warning('金额不能为 0(且不小于 1 分)'); + return; + } + if (mode === 'set' && amountCents < 0) { + message.warning('目标金额不能为负'); + return; + } + try { + await api.post(`/admin/api/users/${user.id}/cash`, { + mode, + amount_cents: amountCents, + reason: v.reason, + }); + message.success( + mode === 'set' + ? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金` + : `已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`, + ); + onClose(); + onDone?.(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..8a621e3 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,47 @@ +// 全站统一的金额 / 时间格式化。 +// +// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数: +// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。 +// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。 +// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即 +// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。 +// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC), +// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。 +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + +const TZ = 'Asia/Shanghai'; + +/** 金额:分 → ¥元(两位小数)。 */ +export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`; + +const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); + +/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */ +export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`); + +/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */ +export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string { + if (!v) return '-'; + return utcDayjs(v).tz(TZ).format(fmt); +} + +/** UTC 口径相对时间(如「3 分钟前」)。 */ +export function utcFromNow(v?: string | null): string { + if (!v) return '-'; + return utcDayjs(v).fromNow(); +} + +/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */ +export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string { + if (!v) return '-'; + return dayjs(v).format(fmt); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index aacef43..febd7c4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,7 @@ export interface LoginResponse { export interface CursorPage { items: T[]; next_cursor: number | null; + total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有 } export interface UserListItem { @@ -36,6 +37,13 @@ export interface UserListItem { last_login_at: string; } +// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。 +export interface DeviceOnboardingItem { + device_id: string; + account_count: number; + last_completed_at: string; +} + export interface UserOverview { user: UserListItem; coin_balance: number; @@ -225,39 +233,136 @@ export interface AuditLog { created_at: string; } -export interface AdCoinAuditRow { - scene: string; // reward_video / feed - record_id: number; +// 广告收益报表聚合行下钻的单条展示明细 +// ===== 比价记录(admin debug 页)===== +export interface ComparisonRecordListItem { + id: number; user_id: number; + phone: string | null; + nickname: string | null; + business_type: string; + trace_id: string; + trace_url: string | null; + status: string; + information: string | null; + store_name: string | null; + source_platform_name: string | null; + best_platform_name: string | null; + source_price_cents: number | null; + best_price_cents: number | null; + saved_amount_cents: number | null; + total_ms: number | null; + step_count: number | null; + llm_call_count: number | null; + retry_count: number | null; + device_model: string | null; + rom_vendor: string | null; + rom_name: string | null; + android_version: string | null; + app_version: string | null; created_at: string; - status: string; // granted / capped / ecpm_missing +} + +// 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来) +export interface LlmCall { + ts?: number; + scene: string; + model: string; + input_messages: { role: string; content: string }[]; + output: string | null; + usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null; + latency_ms: number | null; + error: string | null; +} + +export interface ComparisonRecordDetail extends ComparisonRecordListItem { + source_platform_id: string | null; + source_package: string | null; + best_platform_id: string | null; + best_deeplink: string | null; + is_source_best: boolean | null; + total_dish_count: number | null; + skipped_dish_count: number | null; + device_id: string | null; + items: { name: string; qty?: number; specs?: string[] }[]; + comparison_results: Record[]; + skipped_dish_names: string[]; + device_manufacturer: string | null; + rom_version: number | null; + android_sdk: number | null; + app_version_code: number | null; + source_app_version: string | null; + longitude: number | null; + latitude: number | null; + llm_calls: LlmCall[] | null; + raw_payload: Record | null; +} + +export interface AdRevenueImpression { + id: number; + created_at: string; + ecpm: string; // 分/千次展示 + revenue_yuan: number; // 本次展示预估收益(元) + adn: string | null; + slot_id: string | null; // 底层 mediation rit +} + +// 广告收益报表聚合行下钻的单条发奖复算明细 +export interface AdRevenueRecord { + record_id: number; + created_at: string; + status: string; // granted / capped / ecpm_missing / too_short / closed_early ecpm: string | null; - ecpm_factor: number | null; - units: number; + ecpm_factor: number | null; // 因子1 + units: number; // 份数 lt_index_start: number | null; lt_index_end: number | null; - lt_factor_start: number | null; + lt_factor_start: number | null; // 因子2 lt_factor_end: number | null; expected_coin: number; actual_coin: number; matched: boolean; } -export interface AdCoinFormula { - description: string; - coin_per_yuan: number; - ecpm_unit: string; - feed_unit_seconds: number; - ecpm_factor_tiers: [number, number, number | null][]; - lt_factor_tiers: [number, number, number | null][]; +// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响) +export interface AdRevenueDaily { + date: string; // 北京时间 YYYY-MM-DD + impressions: number; + revenue_yuan: number; + expected_coin: number; + actual_coin: number; } -export interface AdCoinAudit { - date: string; - formula: AdCoinFormula; - total: number; - mismatch_count: number; - items: AdCoinAuditRow[]; +// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合 +export interface AdRevenueRow { + report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD + user_id: number; + ad_type: string; // reward_video / feed / draw + app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空 + our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空 + hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null) + impressions: number; // 展示条数(轮播每条各计一次) + revenue_yuan: number; // 收益(元),预估口径 + expected_coin: number; // 应发金币(公式复算,与金币审计同源) + actual_coin: number; // 实发金币(实际入账) + matched: boolean; // 该组应发==实发 + adns: string[]; // 底层填充 ADN 子渠道集合 + impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻) + records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻) +} + +export interface AdRevenueReport { + date_from: string; // 起始日 北京时间 YYYY-MM-DD + date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) + daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) + total: number; // 聚合组总数(全量,不受 limit 影响) + truncated: boolean; + total_impressions: number; + total_revenue_yuan: number; + total_expected_coin: number; + total_actual_coin: number; + mismatch_count: number; // 应发≠实发的组数 + items: AdRevenueRow[]; } export interface DashboardOverview { @@ -307,3 +412,88 @@ export interface PriceReportSummary { rejected: number; total: number; } + +// ===== CPS 分发与对账 ===== +export interface CpsGroup { + id: number; + sid: string | null; // 仅美团群有(渠道追踪位) + name: string; + platforms: string[]; // meituan/taobao/jd 多选 + member_count: number | null; + status: string; // active / archived + remark: string | null; + created_at: string; +} + +export interface CpsActivity { + id: number; + platform: string; // meituan / taobao / jd + name: string; + act_id: string | null; // 美团活动物料 ID + product_view_sign: string | null; + payload: string | null; // 淘宝整段淘口令 / 京东链接 + image_url: string | null; // 淘宝落地页主视觉图(绝对 URL) + status: string; + remark: string | null; + created_at: string; +} + +export interface CpsOrder { + id: number; + order_id: string; + sid: string | null; + act_id: string | null; + pay_price_cents: number | null; + commission_cents: number | null; + commission_rate: string | null; // "300"=3% "10"=0.1% + mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算 + invalid_reason: string | null; + product_name: string | null; + pay_time: string | null; +} + +export interface CpsReferralLinkItem { + activity_id: number; + activity_name: string; + platform: string; + redirect_url: string; // 我们的落地页 /c/{code}(发群用) + code: string; +} + +export interface CpsReferralLinks { + group_name: string; + results: CpsReferralLinkItem[]; +} + +export interface CpsReconcileResult { + fetched: number; + inserted: number; + updated: number; + pages: number; +} + +export interface CpsGroupStat { + group_id: number | null; + sid: string | null; + name: string; + platforms: string[]; + member_count: number | null; + click_pv: number; + click_uv: number; + copy_pv: number; // 淘宝"复制口令"次数 + copy_uv: number; + // 对账类:淘宝/京东无法对账 → null(前端显示 "-") + order_count: number | null; + settled_count: number | null; + canceled_count: number | null; + gmv_cents: number | null; + est_commission_cents: number | null; + settled_commission_cents: number | null; +} + +export interface CpsStats { + groups: CpsGroupStat[]; + total_order_count: number; + total_est_commission_cents: number; + total_settled_commission_cents: number; +} diff --git a/src/lib/usePagedList.ts b/src/lib/usePagedList.ts new file mode 100644 index 0000000..00cad8f --- /dev/null +++ b/src/lib/usePagedList.ts @@ -0,0 +1,75 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from './api'; +import type { CursorPage } from './types'; + +/** + * 页码分页列表 hook(对接后端 offset 分页 + total)。 + * 与 useCursorList(「加载更多」)并存:需要页码/跳页/共 N 条的主列表页用本 hook。 + * + * 约定:后端 cursor 即 offset,本 hook 按 (page-1)*pageSize 计算;CursorPage.total 为符合 + * 筛选条件的总条数(供 antd Table pagination 渲染)。filters 变化自动回第 1 页重载。 + */ +export function usePagedList( + url: string, + filters: Record, + initialPageSize = 20, +) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(initialPageSize); + const [loading, setLoading] = useState(false); + const requestSeq = useRef(0); + + const filtersKey = JSON.stringify(filters); + + const load = useCallback( + async (p: number, ps: number) => { + const seq = requestSeq.current + 1; + requestSeq.current = seq; + setLoading(true); + try { + const params = { ...JSON.parse(filtersKey), limit: ps, cursor: (p - 1) * ps }; + const { data } = await api.get>(url, { params }); + if (requestSeq.current !== seq) return; // 丢弃过期响应(快速切页/筛选) + setItems(data.items); + setTotal(data.total ?? 0); + } finally { + if (requestSeq.current === seq) setLoading(false); + } + }, + [url, filtersKey], + ); + + // 筛选变化:回第 1 页重载 + useEffect(() => { + setPage(1); + load(1, pageSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [load]); + + // antd Table pagination.onChange(page, pageSize):页变或每页条数变都走这里 + const onChange = (p: number, ps: number) => { + if (ps !== pageSize) { + // 每页条数变 → 回第 1 页,避免越界空页 + setPageSize(ps); + setPage(1); + load(1, ps); + } else { + setPage(p); + load(p, ps); + } + }; + + return { + items, + total, + page, + pageSize, + loading, + onChange, + reload: () => load(page, pageSize), // 写操作后刷新当前页 + }; +}