feat(be+admin): 后台看板扩展 + 社区内容安全审核
看板(Recharts): - 概览页加 DAU/WAU/MAU、今日/本月新增 KPI,新增趋势/活跃趋势/月活/留存曲线 - GET /admin/analytics 内存计算,活跃口径=当天有记录/发帖/评论,排除 bot 内容安全(微信官方 UGC): - 文本 msg_sec_check 同步判、图片 media_check_async 异步查 - 帖子/评论加 pending/rejected 审核态,feed 只放 published - 图片结果回调 /api/wx/sec-callback,JSON/XML + 明文模式,签名校验 - 检测不了/未发布时一律转待审核,走后台手动审核 - 后台帖子/评论审核页:修好看不到图(补 attachPostImages)、 加状态筛选 + 通过/打回;新增评论审核状态接口 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+389
@@ -18,6 +18,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -844,6 +845,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
@@ -1108,6 +1135,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -1119,6 +1158,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@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.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@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.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@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-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
@@ -1149,6 +1251,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||
@@ -1526,6 +1634,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/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-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/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-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1543,6 +1772,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1648,6 +1883,17 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
|
||||
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1658,6 +1904,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -1926,6 +2178,25 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.15",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
|
||||
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2742,6 +3013,36 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -2885,6 +3186,57 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3105,6 +3457,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3243,6 +3601,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3250,6 +3617,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@ const badgeVariants = cva(
|
||||
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-emerald-100 text-emerald-700',
|
||||
warning: 'border-transparent bg-amber-100 text-amber-700',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export const api = {
|
||||
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
|
||||
me: () => http.get<any, any>('/admin/me'),
|
||||
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
|
||||
analytics: (days = 30) => http.get<any, any>('/admin/analytics', { params: { days } }),
|
||||
|
||||
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
|
||||
setUserDisabled: (id: number, disabled: boolean) =>
|
||||
@@ -112,6 +113,7 @@ export const api = {
|
||||
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
|
||||
|
||||
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
|
||||
setCommentStatus: (id: number, status: string) => http.put(`/admin/comments/${id}/status`, { status }),
|
||||
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
|
||||
|
||||
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { usePaged } from '@/lib/usePaged'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
@@ -6,9 +7,31 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Pager } from '@/components/Pager'
|
||||
|
||||
export default function Comments() {
|
||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments)
|
||||
const statusMap: Record<string, { label: string; variant: any }> = {
|
||||
pending: { label: '待审核', variant: 'warning' },
|
||||
published: { label: '正常', variant: 'success' },
|
||||
rejected: { label: '已打回', variant: 'destructive' },
|
||||
deleted: { label: '已删除', variant: 'destructive' },
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ label: '待审核', status: 'pending' },
|
||||
{ label: '正常', status: 'published' },
|
||||
{ label: '全部', status: '' },
|
||||
]
|
||||
|
||||
function isUrl(s: string) {
|
||||
return typeof s === 'string' && s.indexOf('http') === 0
|
||||
}
|
||||
|
||||
export default function Comments() {
|
||||
const [status, setStatus] = useState('pending')
|
||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments, { status })
|
||||
|
||||
async function setStat(id: number, s: string) {
|
||||
await api.setCommentStatus(id, s)
|
||||
reload()
|
||||
}
|
||||
async function del(id: number) {
|
||||
await api.deleteComment(id)
|
||||
reload()
|
||||
@@ -16,39 +39,83 @@ export default function Comments() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">评论管理</h1>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">评论审核</h1>
|
||||
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.status}
|
||||
onClick={() => { setStatus(f.status); setPage(1) }}
|
||||
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>帖子ID</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>配图</TableHead>
|
||||
<TableHead>帖子</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.list?.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.id}</TableCell>
|
||||
<TableCell>{c.post_id}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{c.author_name}</TableCell>
|
||||
<TableCell className="max-w-sm truncate">{c.content}</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'deleted' ? <Badge variant="destructive">已删除</Badge> : <Badge variant="success">正常</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{c.status !== 'deleted' && (
|
||||
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
{data?.list?.map((c) => {
|
||||
const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
|
||||
<TableCell className="max-w-xs align-top">
|
||||
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{imgs.length ? (
|
||||
<div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
|
||||
{imgs.map((u, i) => (
|
||||
<a key={i} href={u} target="_blank" rel="noreferrer">
|
||||
<img src={u} className="h-14 w-14 rounded-md border object-cover" loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">无</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-xs text-muted-foreground">{c.post_id}</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge variant={statusMap[c.status]?.variant}>{statusMap[c.status]?.label ?? c.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
|
||||
{c.status !== 'published' && c.status !== 'deleted' && (
|
||||
<Button variant="default" size="sm" onClick={() => setStat(c.id, 'published')}>
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
{c.status !== 'deleted' && (
|
||||
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{!data?.list?.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||
这个状态下暂无评论
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
||||
|
||||
@@ -1,39 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users, PawPrint, MessageSquare, Activity } from 'lucide-react'
|
||||
import { Users, PawPrint, MessageSquare, Activity, TrendingUp, CalendarDays, UserPlus, Repeat } from 'lucide-react'
|
||||
import {
|
||||
ResponsiveContainer, AreaChart, Area, BarChart, Bar, LineChart, Line,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Cell,
|
||||
} from 'recharts'
|
||||
import { api } from '@/lib/api'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
type Stats = { users: number; pets: number; posts: number; records: number }
|
||||
const ORANGE = '#F5A93E'
|
||||
const GREEN = '#3FB984'
|
||||
const BLUE = '#5B9BD5'
|
||||
|
||||
type Pt = { date: string; count: number }
|
||||
type Ret = { day: number; rate: number; base: number }
|
||||
type Analytics = {
|
||||
summary: {
|
||||
total_users: number; new_today: number; new_month: number
|
||||
dau: number; wau: number; mau: number
|
||||
total_pets: number; total_posts: number; total_records: number
|
||||
}
|
||||
new_trend: Pt[]; active_trend: Pt[]; mau_trend: Pt[]; retention: Ret[]
|
||||
}
|
||||
|
||||
const RANGES = [
|
||||
{ label: '近 7 天', days: 7 },
|
||||
{ label: '近 30 天', days: 30 },
|
||||
{ label: '近 90 天', days: 90 },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
useEffect(() => {
|
||||
api.stats().then(setStats).catch(() => {})
|
||||
}, [])
|
||||
const [days, setDays] = useState(30)
|
||||
const [a, setA] = useState<Analytics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const cards = [
|
||||
{ label: '用户', value: stats?.users, icon: Users },
|
||||
{ label: '宠物', value: stats?.pets, icon: PawPrint },
|
||||
{ label: '帖子', value: stats?.posts, icon: MessageSquare },
|
||||
{ label: '健康记录', value: stats?.records, icon: Activity },
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
|
||||
}, [days])
|
||||
|
||||
const s = a?.summary
|
||||
const kpis = [
|
||||
{ label: '真实用户', value: s?.total_users, icon: Users, tint: ORANGE },
|
||||
{ label: '日活 DAU', value: s?.dau, icon: Activity, tint: GREEN },
|
||||
{ label: '周活 WAU', value: s?.wau, icon: CalendarDays, tint: BLUE },
|
||||
{ label: '月活 MAU', value: s?.mau, icon: TrendingUp, tint: ORANGE },
|
||||
{ label: '今日新增', value: s?.new_today, icon: UserPlus, tint: GREEN },
|
||||
{ label: '本月新增', value: s?.new_month, icon: UserPlus, tint: BLUE },
|
||||
]
|
||||
const mini = [
|
||||
{ label: '宠物', value: s?.total_pets, icon: PawPrint },
|
||||
{ label: '帖子', value: s?.total_posts, icon: MessageSquare },
|
||||
{ label: '健康记录', value: s?.total_records, icon: Activity },
|
||||
]
|
||||
const retData = (a?.retention || []).map((r) => ({ ...r, pct: Math.round(r.rate * 1000) / 10 }))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">概览</h1>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((c) => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">概览</h1>
|
||||
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.days}
|
||||
onClick={() => setDays(r.days)}
|
||||
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||
days === r.days ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 卡 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
|
||||
{kpis.map((c) => (
|
||||
<Card key={c.label}>
|
||||
<CardHeader className="flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">{c.label}</CardTitle>
|
||||
<c.icon className="h-4 w-4 text-primary" />
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
|
||||
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
|
||||
<c.icon className="h-4 w-4" style={{ color: c.tint }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{c.value ?? '—'}</div>
|
||||
<div className="text-2xl font-bold">{c.value ?? '—'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 图表区 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="新增趋势" hint={`每日新增真实用户 · ${days} 天`}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={a?.new_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gNew" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={ORANGE} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={ORANGE} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||
<Tooltip {...tipProps} />
|
||||
<Area type="monotone" dataKey="count" name="新增" stroke={ORANGE} strokeWidth={2} fill="url(#gNew)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="活跃趋势" hint={`每日活跃用户 DAU · ${days} 天`}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={a?.active_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||
<Tooltip {...tipProps} />
|
||||
<Line type="monotone" dataKey="count" name="活跃" stroke={GREEN} strokeWidth={2.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="月活 MAU" hint="近 6 个月去重活跃用户">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={a?.mau_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||
<Tooltip {...tipProps} />
|
||||
<Bar dataKey="count" name="月活" fill={BLUE} radius={[6, 6, 0, 0]} maxBarSize={44} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="留存曲线" hint="注册后第 N 天仍活跃的比例(滚动口径)">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={retData} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={(d) => `D${d}`} />
|
||||
<YAxis tick={{ fontSize: 11 }} width={40} unit="%" domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={tipProps.contentStyle}
|
||||
formatter={(v: any, _n: any, p: any) => [`${v}%(分母 ${p.payload.base})`, '留存率']}
|
||||
labelFormatter={(d) => `注册后第 ${d} 天`}
|
||||
/>
|
||||
<Line type="monotone" dataKey="pct" name="留存率" stroke={ORANGE} strokeWidth={2.5}>
|
||||
{retData.map((_, i) => <Cell key={i} />)}
|
||||
</Line>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* 底部小计数 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{mini.map((c) => (
|
||||
<Card key={c.label}>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
|
||||
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
|
||||
<c.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{c.value ?? '—'}</div></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-center text-sm text-muted-foreground">加载中…</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tipProps = {
|
||||
contentStyle: { borderRadius: 10, border: '1px solid #eee', fontSize: 12 },
|
||||
cursor: { fill: 'rgba(245,169,62,.08)' },
|
||||
}
|
||||
|
||||
function ChartCard({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { usePaged } from '@/lib/usePaged'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
@@ -7,12 +8,25 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Pager } from '@/components/Pager'
|
||||
|
||||
const statusMap: Record<string, { label: string; variant: any }> = {
|
||||
pending: { label: '待审核', variant: 'warning' },
|
||||
published: { label: '已发布', variant: 'success' },
|
||||
rejected: { label: '已打回', variant: 'destructive' },
|
||||
hidden: { label: '已隐藏', variant: 'muted' },
|
||||
deleted: { label: '已删除', variant: 'destructive' },
|
||||
}
|
||||
|
||||
// 今天的只看时分,往前的看月日——审核时关心的是「多久以前发的」
|
||||
const FILTERS = [
|
||||
{ label: '待审核', status: 'pending' },
|
||||
{ label: '已发布', status: 'published' },
|
||||
{ label: '已打回', status: 'rejected' },
|
||||
{ label: '全部', status: '' },
|
||||
]
|
||||
|
||||
// 后台展示的图片可能是真实 URL,也可能是机器人帖的 emoji 占位——只渲染 URL
|
||||
function isUrl(s: string) {
|
||||
return typeof s === 'string' && s.indexOf('http') === 0
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
@@ -21,72 +35,115 @@ function fmtTime(iso?: string) {
|
||||
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
const now = new Date()
|
||||
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
|
||||
const y = new Date(now.getTime() - 86400000)
|
||||
if (d.toDateString() === y.toDateString()) return `昨天 ${hm}`
|
||||
const sameYear = d.getFullYear() === now.getFullYear()
|
||||
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts)
|
||||
const [status, setStatus] = useState('pending')
|
||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts, { status })
|
||||
|
||||
async function setStatus(id: number, status: string) {
|
||||
await api.setPostStatus(id, status)
|
||||
async function setPostStatus(id: number, s: string) {
|
||||
await api.setPostStatus(id, s)
|
||||
reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">帖子审核</h1>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">帖子审核</h1>
|
||||
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.status}
|
||||
onClick={() => { setStatus(f.status); setPage(1) }}
|
||||
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>配图</TableHead>
|
||||
<TableHead>赞/评</TableHead>
|
||||
<TableHead>发布时间</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.list?.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.id}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<span className="mr-1">{p.author_emoji}</span>
|
||||
{p.author_name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm truncate">{p.content}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{p.like_count} / {p.comment_count}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{fmtTime(p.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2 whitespace-nowrap">
|
||||
{p.status !== 'published' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'published')}>
|
||||
恢复
|
||||
{data?.list?.map((p) => {
|
||||
const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="whitespace-nowrap align-top">
|
||||
<span className="mr-1">{p.author_emoji}</span>
|
||||
{p.author_name}
|
||||
{p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs align-top">
|
||||
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{imgs.length ? (
|
||||
<div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
|
||||
{imgs.map((u, i) => (
|
||||
<a key={i} href={u} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={u}
|
||||
className="h-16 w-16 rounded-md object-cover border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">无</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap align-top">
|
||||
{p.like_count} / {p.comment_count}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap align-top text-muted-foreground">
|
||||
{fmtTime(p.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
|
||||
{p.status !== 'published' && (
|
||||
<Button variant="default" size="sm" onClick={() => setPostStatus(p.id, 'published')}>
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
{(p.status === 'pending' || p.status === 'published') && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPostStatus(p.id, 'rejected')}>
|
||||
打回
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" size="sm" onClick={() => setPostStatus(p.id, 'deleted')}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
{p.status === 'published' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'hidden')}>
|
||||
隐藏
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" size="sm" onClick={() => setStatus(p.id, 'deleted')}>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{!data?.list?.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
这个状态下暂无帖子
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
||||
|
||||
Reference in New Issue
Block a user