diff --git a/README.md b/README.md index 18bc70e..8e0f40a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ -# React + Vite +# STAR International UI -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React **18** + Vite 国际站前端。 -Currently, two official plugins are available: +- 公众站:对齐国内 STAR 站信息架构与视觉(顶栏 / Logo 搜索 / 导航 / 页脚),组件以 Tailwind + shadcn 风格轻量组件为主;图片资源来自国内站 `assets/img` +- 管理端:`/admin` 仍使用 Ant Design,便于 CRUD +- 中英文切换仅作用于 UI 文案(`react-i18next`) -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +## 开发 -## React Compiler +```bash +npm install +npm run dev +``` -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +需先启动 API:`/root/api/star-international-api`(默认代理 `/api` → `127.0.0.1:8000`)。 -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +后台演示账号:`admin` / `admin123` diff --git a/index.html b/index.html index 4d1845b..9bd079e 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - template-ui + STAR International
diff --git a/package-lock.json b/package-lock.json index b7ab6d1..c5db108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,23 +16,35 @@ "@rematch/loading": "^2.1.2", "@rematch/persist": "^2.1.2", "@rematch/select": "^3.1.2", + "@tiptap/extension-link": "^3.31.4", + "@tiptap/extension-placeholder": "^3.31.4", + "@tiptap/extension-underline": "^3.31.4", + "@tiptap/pm": "^3.31.4", + "@tiptap/react": "^3.31.4", + "@tiptap/starter-kit": "^3.31.4", "ahooks": "^3.8.4", "antd": "^5.24.2", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dayjs": "^1.11.13", + "i18next": "^26.4.2", "lodash-es": "^4.17.21", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "lucide-react": "^1.53.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.16", "react-redux": "^9.2.0", "react-router-dom": "^7.9.6", "redux": "^5.0.1", + "tailwind-merge": "^3.7.0", "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/postcss": "^4.1.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.1.0", "autoprefixer": "^10.4.20", "eslint": "^9.39.1", @@ -134,9 +146,9 @@ } }, "node_modules/@ant-design/icons-svg": { - "version": "4.4.2", - "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", - "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz", + "integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==", "license": "MIT" }, "node_modules/@ant-design/react-slick": { @@ -391,9 +403,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1112,6 +1124,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, "node_modules/@formily/antd-v5": { "version": "1.2.4", "resolved": "https://registry.npmmirror.com/@formily/antd-v5/-/antd-v5-1.2.4.tgz", @@ -1409,9 +1438,9 @@ "license": "Apache-2.0" }, "node_modules/@rc-component/async-validator": { - "version": "5.0.4", - "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.0.4.tgz", - "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.4" @@ -1451,9 +1480,9 @@ } }, "node_modules/@rc-component/mini-decimal": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", - "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.0" @@ -1499,13 +1528,12 @@ } }, "node_modules/@rc-component/qrcode": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.0.tgz", - "integrity": "sha512-ABA80Yer0c6I2+moqNY0kF3Y1NxIT6wDP/EINIqbiRbfZKP1HtHpKMh8WuTXLgVGYsoWG2g9/n0PgM8KdnJb4Q==", + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.24.7", - "classnames": "^2.3.2" + "@babel/runtime": "^7.24.7" }, "engines": { "node": ">=8.x" @@ -1536,9 +1564,9 @@ } }, "node_modules/@rc-component/trigger": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.3.0.tgz", - "integrity": "sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==", + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2", @@ -2190,6 +2218,453 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tiptap/core": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz", + "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz", + "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz", + "integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.4.tgz", + "integrity": "sha512-Ml/yswuys+3QkOMajfXyAGlUKCxEbT3dMyF8vHwQ5mDk9MDm+dqp10It5CrDBkUT/KkVTa6AJI3kb0TXZJZiAA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz", + "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz", + "integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz", + "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz", + "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz", + "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz", + "integrity": "sha512-pKzulkDmolpVt7IP9FSPsC5pKbXQ5pyeqkaAYr8cyOVW+KUObpPRIzAuXvEso8PCjWgwJ1f2qyishOEMw8MbfQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz", + "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz", + "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz", + "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz", + "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", + "integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz", + "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz", + "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz", + "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz", + "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz", + "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz", + "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.4.tgz", + "integrity": "sha512-3SoJopaOzaA9IZHSk9ZXHiO6ib8ErPh7ssRh3bIaxfD+BqRyXcm4hV4yfFXTqrDCnl5FE+DbBRGmeVAVkDko3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz", + "integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz", + "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz", + "integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz", + "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz", + "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.4.tgz", + "integrity": "sha512-mBvvXgdPhMinT1NPfwHvFK1dFdMXHVWMSeNn0IPj7/zV0XBmOnASkEOwoIvl8NluKYEtpZBE1yA+6pN2enLa5g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "3.31.4", + "@tiptap/extension-floating-menu": "3.31.4" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz", + "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/extension-blockquote": "3.31.4", + "@tiptap/extension-bold": "3.31.4", + "@tiptap/extension-bullet-list": "3.31.4", + "@tiptap/extension-code": "3.31.4", + "@tiptap/extension-code-block": "3.31.4", + "@tiptap/extension-document": "3.31.4", + "@tiptap/extension-dropcursor": "3.31.4", + "@tiptap/extension-gapcursor": "3.31.4", + "@tiptap/extension-hard-break": "3.31.4", + "@tiptap/extension-heading": "3.31.4", + "@tiptap/extension-horizontal-rule": "3.31.4", + "@tiptap/extension-italic": "3.31.4", + "@tiptap/extension-link": "3.31.4", + "@tiptap/extension-list": "3.31.4", + "@tiptap/extension-list-item": "3.31.4", + "@tiptap/extension-list-keymap": "3.31.4", + "@tiptap/extension-ordered-list": "3.31.4", + "@tiptap/extension-paragraph": "3.31.4", + "@tiptap/extension-strike": "3.31.4", + "@tiptap/extension-text": "3.31.4", + "@tiptap/extension-underline": "3.31.4", + "@tiptap/extensions": "3.31.4", + "@tiptap/pm": "3.31.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2255,24 +2730,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "version": "18.3.12", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", "license": "MIT", "peer": true, "dependencies": { + "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "license": "MIT", "peer": true, - "peerDependencies": { - "@types/react": "^19.2.0" + "dependencies": { + "@types/react": "*" } }, "node_modules/@types/use-sync-external-store": { @@ -2665,12 +3147,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", @@ -2733,12 +3236,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/copy-to-clipboard": { @@ -3173,6 +3680,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz", + "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3495,6 +4011,44 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -3574,7 +4128,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -3592,7 +4145,6 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3694,7 +4246,6 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3950,6 +4501,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -3985,6 +4542,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/lower-case/-/lower-case-2.0.2.tgz", @@ -4004,6 +4573,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.53.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.53.0.tgz", + "integrity": "sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -4135,6 +4719,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", @@ -4302,6 +4892,145 @@ "node": ">= 0.8.0" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4675,9 +5404,9 @@ } }, "node_modules/rc-segmented": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.0.tgz", - "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.1", @@ -4931,26 +5660,30 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmmirror.com/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.27.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^18.3.1" } }, "node_modules/react-fast-compare": { @@ -4959,6 +5692,33 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "license": "MIT" }, + "node_modules/react-i18next": { + "version": "17.0.16", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-17.0.16.tgz", + "integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.0", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.0.tgz", @@ -5129,12 +5889,21 @@ "fsevents": "~2.3.2" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/screenfull": { "version": "5.2.0", "resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz", @@ -5237,9 +6006,9 @@ } }, "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, "node_modules/supports-color": { @@ -5255,6 +6024,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -5476,6 +6255,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fb2344e..8ca2371 100644 --- a/package.json +++ b/package.json @@ -18,32 +18,44 @@ "@rematch/loading": "^2.1.2", "@rematch/persist": "^2.1.2", "@rematch/select": "^3.1.2", + "@tiptap/extension-link": "^3.31.4", + "@tiptap/extension-placeholder": "^3.31.4", + "@tiptap/extension-underline": "^3.31.4", + "@tiptap/pm": "^3.31.4", + "@tiptap/react": "^3.31.4", + "@tiptap/starter-kit": "^3.31.4", "ahooks": "^3.8.4", "antd": "^5.24.2", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dayjs": "^1.11.13", + "i18next": "^26.4.2", "lodash-es": "^4.17.21", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "lucide-react": "^1.53.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.16", "react-redux": "^9.2.0", "react-router-dom": "^7.9.6", "redux": "^5.0.1", + "tailwind-merge": "^3.7.0", "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", + "@tailwindcss/postcss": "^4.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.1.0", + "autoprefixer": "^10.4.20", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "vite": "^7.2.2", - "tailwindcss": "^4.1.17", - "@tailwindcss/postcss": "^4.1.0", "postcss": "^8.5.1", "postcss-antd-fixes": "^0.2.0", - "autoprefixer": "^10.4.20" + "tailwindcss": "^4.1.17", + "vite": "^7.2.2" } } diff --git a/postcss.config.js b/postcss.config.js index a35d903..c2ddf74 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,5 @@ export default { plugins: { "@tailwindcss/postcss": {}, - autoprefixer: {}, - "postcss-antd-fixes": {}, }, }; diff --git a/public/assets/img/01.png b/public/assets/img/01.png new file mode 100644 index 0000000..b367e54 Binary files /dev/null and b/public/assets/img/01.png differ diff --git a/public/assets/img/01@2x.jpg b/public/assets/img/01@2x.jpg new file mode 100644 index 0000000..247e4ce Binary files /dev/null and b/public/assets/img/01@2x.jpg differ diff --git a/public/assets/img/02.png b/public/assets/img/02.png new file mode 100644 index 0000000..cdb1c39 Binary files /dev/null and b/public/assets/img/02.png differ diff --git a/public/assets/img/02@2x.jpg b/public/assets/img/02@2x.jpg new file mode 100644 index 0000000..8df7a79 Binary files /dev/null and b/public/assets/img/02@2x.jpg differ diff --git a/public/assets/img/03@2x.jpg b/public/assets/img/03@2x.jpg new file mode 100644 index 0000000..a3a18c9 Binary files /dev/null and b/public/assets/img/03@2x.jpg differ diff --git a/public/assets/img/04@2x.jpg b/public/assets/img/04@2x.jpg new file mode 100644 index 0000000..cee1de8 Binary files /dev/null and b/public/assets/img/04@2x.jpg differ diff --git a/public/assets/img/1.jpg b/public/assets/img/1.jpg new file mode 100644 index 0000000..92d6ee5 Binary files /dev/null and b/public/assets/img/1.jpg differ diff --git a/public/assets/img/11.jpg b/public/assets/img/11.jpg new file mode 100644 index 0000000..40942c9 Binary files /dev/null and b/public/assets/img/11.jpg differ diff --git a/public/assets/img/2.jpg b/public/assets/img/2.jpg new file mode 100644 index 0000000..139f2f7 Binary files /dev/null and b/public/assets/img/2.jpg differ diff --git a/public/assets/img/3.jpg b/public/assets/img/3.jpg new file mode 100644 index 0000000..b5bb20e Binary files /dev/null and b/public/assets/img/3.jpg differ diff --git a/public/assets/img/4.jpg b/public/assets/img/4.jpg new file mode 100644 index 0000000..3759cd5 Binary files /dev/null and b/public/assets/img/4.jpg differ diff --git a/public/assets/img/5.jpg b/public/assets/img/5.jpg new file mode 100644 index 0000000..450c81f Binary files /dev/null and b/public/assets/img/5.jpg differ diff --git a/public/assets/img/6.jpg b/public/assets/img/6.jpg new file mode 100644 index 0000000..22c0d23 Binary files /dev/null and b/public/assets/img/6.jpg differ diff --git a/public/assets/img/9.jpg b/public/assets/img/9.jpg new file mode 100644 index 0000000..12127eb Binary files /dev/null and b/public/assets/img/9.jpg differ diff --git a/public/assets/img/WechatIMG26.png b/public/assets/img/WechatIMG26.png new file mode 100644 index 0000000..a01662d Binary files /dev/null and b/public/assets/img/WechatIMG26.png differ diff --git a/public/assets/img/about/pingji.png b/public/assets/img/about/pingji.png new file mode 100644 index 0000000..9c87b51 Binary files /dev/null and b/public/assets/img/about/pingji.png differ diff --git a/public/assets/img/about/rating-steps.png b/public/assets/img/about/rating-steps.png new file mode 100644 index 0000000..54196e3 Binary files /dev/null and b/public/assets/img/about/rating-steps.png differ diff --git a/public/assets/img/about/yanfa.png b/public/assets/img/about/yanfa.png new file mode 100644 index 0000000..d25386c Binary files /dev/null and b/public/assets/img/about/yanfa.png differ diff --git a/public/assets/img/about/yanzheng.png b/public/assets/img/about/yanzheng.png new file mode 100644 index 0000000..e6dcd6b Binary files /dev/null and b/public/assets/img/about/yanzheng.png differ diff --git a/public/assets/img/address.png b/public/assets/img/address.png new file mode 100644 index 0000000..22ce652 Binary files /dev/null and b/public/assets/img/address.png differ diff --git a/public/assets/img/banner1@2x(1).jpg b/public/assets/img/banner1@2x(1).jpg new file mode 100644 index 0000000..12127eb Binary files /dev/null and b/public/assets/img/banner1@2x(1).jpg differ diff --git a/public/assets/img/banner1@2x.jpg b/public/assets/img/banner1@2x.jpg new file mode 100644 index 0000000..0507974 Binary files /dev/null and b/public/assets/img/banner1@2x.jpg differ diff --git a/public/assets/img/banner2@2x.jpg b/public/assets/img/banner2@2x.jpg new file mode 100644 index 0000000..a14bdc5 Binary files /dev/null and b/public/assets/img/banner2@2x.jpg differ diff --git a/public/assets/img/banner3@2x.jpg b/public/assets/img/banner3@2x.jpg new file mode 100644 index 0000000..4f4a54a Binary files /dev/null and b/public/assets/img/banner3@2x.jpg differ diff --git a/public/assets/img/banquanshengming-bg@2x.png b/public/assets/img/banquanshengming-bg@2x.png new file mode 100644 index 0000000..2e19b58 Binary files /dev/null and b/public/assets/img/banquanshengming-bg@2x.png differ diff --git a/public/assets/img/bg.png b/public/assets/img/bg.png new file mode 100644 index 0000000..7b10418 Binary files /dev/null and b/public/assets/img/bg.png differ diff --git a/public/assets/img/bg10@2x.png b/public/assets/img/bg10@2x.png new file mode 100644 index 0000000..b5c1fff Binary files /dev/null and b/public/assets/img/bg10@2x.png differ diff --git a/public/assets/img/bg1@1x.png b/public/assets/img/bg1@1x.png new file mode 100644 index 0000000..3dec77e Binary files /dev/null and b/public/assets/img/bg1@1x.png differ diff --git a/public/assets/img/bg1@2x.png b/public/assets/img/bg1@2x.png new file mode 100644 index 0000000..54ba519 Binary files /dev/null and b/public/assets/img/bg1@2x.png differ diff --git a/public/assets/img/bg2@2x.png b/public/assets/img/bg2@2x.png new file mode 100644 index 0000000..5c0e3b6 Binary files /dev/null and b/public/assets/img/bg2@2x.png differ diff --git a/public/assets/img/bg3@2x.png b/public/assets/img/bg3@2x.png new file mode 100644 index 0000000..6ae7cb3 Binary files /dev/null and b/public/assets/img/bg3@2x.png differ diff --git a/public/assets/img/bg4@2x.png b/public/assets/img/bg4@2x.png new file mode 100644 index 0000000..ed2e389 Binary files /dev/null and b/public/assets/img/bg4@2x.png differ diff --git a/public/assets/img/bg5@2x.png b/public/assets/img/bg5@2x.png new file mode 100644 index 0000000..8cebe12 Binary files /dev/null and b/public/assets/img/bg5@2x.png differ diff --git a/public/assets/img/bg6@2x.png b/public/assets/img/bg6@2x.png new file mode 100644 index 0000000..63f5661 Binary files /dev/null and b/public/assets/img/bg6@2x.png differ diff --git a/public/assets/img/bg7@2x.png b/public/assets/img/bg7@2x.png new file mode 100644 index 0000000..a622ef8 Binary files /dev/null and b/public/assets/img/bg7@2x.png differ diff --git a/public/assets/img/bg8@2x.png b/public/assets/img/bg8@2x.png new file mode 100644 index 0000000..2239bb7 Binary files /dev/null and b/public/assets/img/bg8@2x.png differ diff --git a/public/assets/img/bg9@2x.png b/public/assets/img/bg9@2x.png new file mode 100644 index 0000000..9682707 Binary files /dev/null and b/public/assets/img/bg9@2x.png differ diff --git a/public/assets/img/bukedian-xiala@2x.png b/public/assets/img/bukedian-xiala@2x.png new file mode 100644 index 0000000..9e26f9d Binary files /dev/null and b/public/assets/img/bukedian-xiala@2x.png differ diff --git a/public/assets/img/carousel/jishitang.png b/public/assets/img/carousel/jishitang.png new file mode 100644 index 0000000..9f7fc2d Binary files /dev/null and b/public/assets/img/carousel/jishitang.png differ diff --git a/public/assets/img/carousel/landa.jpg b/public/assets/img/carousel/landa.jpg new file mode 100644 index 0000000..e831c2a Binary files /dev/null and b/public/assets/img/carousel/landa.jpg differ diff --git a/public/assets/img/carousel/yuanshi.png b/public/assets/img/carousel/yuanshi.png new file mode 100644 index 0000000..22787b7 Binary files /dev/null and b/public/assets/img/carousel/yuanshi.png differ diff --git a/public/assets/img/carousel/yuzhong.jpg b/public/assets/img/carousel/yuzhong.jpg new file mode 100644 index 0000000..14ca09f Binary files /dev/null and b/public/assets/img/carousel/yuzhong.jpg differ diff --git a/public/assets/img/daoxu@2x.png b/public/assets/img/daoxu@2x.png new file mode 100644 index 0000000..00dc311 Binary files /dev/null and b/public/assets/img/daoxu@2x.png differ diff --git a/public/assets/img/dianping@2x.png b/public/assets/img/dianping@2x.png new file mode 100644 index 0000000..180484d Binary files /dev/null and b/public/assets/img/dianping@2x.png differ diff --git a/public/assets/img/dianzan@2x(1).png b/public/assets/img/dianzan@2x(1).png new file mode 100644 index 0000000..45d150c Binary files /dev/null and b/public/assets/img/dianzan@2x(1).png differ diff --git a/public/assets/img/dianzan@2x(2).png b/public/assets/img/dianzan@2x(2).png new file mode 100644 index 0000000..20fa530 Binary files /dev/null and b/public/assets/img/dianzan@2x(2).png differ diff --git a/public/assets/img/dianzan@2x.png b/public/assets/img/dianzan@2x.png new file mode 100644 index 0000000..20fa530 Binary files /dev/null and b/public/assets/img/dianzan@2x.png differ diff --git a/public/assets/img/dier@2x.png b/public/assets/img/dier@2x.png new file mode 100644 index 0000000..e9f3fac Binary files /dev/null and b/public/assets/img/dier@2x.png differ diff --git a/public/assets/img/dinahua@2x.png b/public/assets/img/dinahua@2x.png new file mode 100644 index 0000000..404df1b Binary files /dev/null and b/public/assets/img/dinahua@2x.png differ diff --git a/public/assets/img/diqiu@2x.png b/public/assets/img/diqiu@2x.png new file mode 100644 index 0000000..f2a2a17 Binary files /dev/null and b/public/assets/img/diqiu@2x.png differ diff --git a/public/assets/img/disan@2x.png b/public/assets/img/disan@2x.png new file mode 100644 index 0000000..fe775bb Binary files /dev/null and b/public/assets/img/disan@2x.png differ diff --git a/public/assets/img/diyi@2x.png b/public/assets/img/diyi@2x.png new file mode 100644 index 0000000..42703af Binary files /dev/null and b/public/assets/img/diyi@2x.png differ diff --git a/public/assets/img/dunpai1@2x.png b/public/assets/img/dunpai1@2x.png new file mode 100644 index 0000000..311be5e Binary files /dev/null and b/public/assets/img/dunpai1@2x.png differ diff --git a/public/assets/img/dunpai2@2x.png b/public/assets/img/dunpai2@2x.png new file mode 100644 index 0000000..b69a07b Binary files /dev/null and b/public/assets/img/dunpai2@2x.png differ diff --git a/public/assets/img/duoxuan1@2x.png b/public/assets/img/duoxuan1@2x.png new file mode 100644 index 0000000..17d11e6 Binary files /dev/null and b/public/assets/img/duoxuan1@2x.png differ diff --git a/public/assets/img/duoxuan2@2x.png b/public/assets/img/duoxuan2@2x.png new file mode 100644 index 0000000..af63ec7 Binary files /dev/null and b/public/assets/img/duoxuan2@2x.png differ diff --git a/public/assets/img/fenxiang@2x(1).png b/public/assets/img/fenxiang@2x(1).png new file mode 100644 index 0000000..7678dd4 Binary files /dev/null and b/public/assets/img/fenxiang@2x(1).png differ diff --git a/public/assets/img/fenxiang@2x.png b/public/assets/img/fenxiang@2x.png new file mode 100644 index 0000000..7678dd4 Binary files /dev/null and b/public/assets/img/fenxiang@2x.png differ diff --git a/public/assets/img/fh@2x.png b/public/assets/img/fh@2x.png new file mode 100644 index 0000000..e1e3a3e Binary files /dev/null and b/public/assets/img/fh@2x.png differ diff --git a/public/assets/img/gengduo@2x.png b/public/assets/img/gengduo@2x.png new file mode 100644 index 0000000..008bfa4 Binary files /dev/null and b/public/assets/img/gengduo@2x.png differ diff --git a/public/assets/img/geren1@2x.png b/public/assets/img/geren1@2x.png new file mode 100644 index 0000000..00f07a1 Binary files /dev/null and b/public/assets/img/geren1@2x.png differ diff --git a/public/assets/img/geren2@2x.png b/public/assets/img/geren2@2x.png new file mode 100644 index 0000000..aafade7 Binary files /dev/null and b/public/assets/img/geren2@2x.png differ diff --git a/public/assets/img/gongan.png b/public/assets/img/gongan.png new file mode 100644 index 0000000..a341341 Binary files /dev/null and b/public/assets/img/gongan.png differ diff --git a/public/assets/img/guanbi@2x.png b/public/assets/img/guanbi@2x.png new file mode 100644 index 0000000..863bfd3 Binary files /dev/null and b/public/assets/img/guanbi@2x.png differ diff --git a/public/assets/img/guanji1@2x.png b/public/assets/img/guanji1@2x.png new file mode 100644 index 0000000..a63e1c7 Binary files /dev/null and b/public/assets/img/guanji1@2x.png differ diff --git a/public/assets/img/guanji2@2x.png b/public/assets/img/guanji2@2x.png new file mode 100644 index 0000000..2ec21b4 Binary files /dev/null and b/public/assets/img/guanji2@2x.png differ diff --git a/public/assets/img/guanywomen-bg@2x.png b/public/assets/img/guanywomen-bg@2x.png new file mode 100644 index 0000000..7c396dd Binary files /dev/null and b/public/assets/img/guanywomen-bg@2x.png differ diff --git a/public/assets/img/guanzhu1@2x.png b/public/assets/img/guanzhu1@2x.png new file mode 100644 index 0000000..b109e3e Binary files /dev/null and b/public/assets/img/guanzhu1@2x.png differ diff --git a/public/assets/img/guanzhu2@2x.png b/public/assets/img/guanzhu2@2x.png new file mode 100644 index 0000000..2035f10 Binary files /dev/null and b/public/assets/img/guanzhu2@2x.png differ diff --git a/public/assets/img/gywm@2x.jpg b/public/assets/img/gywm@2x.jpg new file mode 100644 index 0000000..14af316 Binary files /dev/null and b/public/assets/img/gywm@2x.jpg differ diff --git a/public/assets/img/hou@2x.png b/public/assets/img/hou@2x.png new file mode 100644 index 0000000..aa28124 Binary files /dev/null and b/public/assets/img/hou@2x.png differ diff --git a/public/assets/img/icon-moren@2x.png b/public/assets/img/icon-moren@2x.png new file mode 100644 index 0000000..9a9da9f Binary files /dev/null and b/public/assets/img/icon-moren@2x.png differ diff --git a/public/assets/img/image-moren@2x.png b/public/assets/img/image-moren@2x.png new file mode 100644 index 0000000..f441cca Binary files /dev/null and b/public/assets/img/image-moren@2x.png differ diff --git a/public/assets/img/jglby@2x.jpg b/public/assets/img/jglby@2x.jpg new file mode 100644 index 0000000..d91e7ba Binary files /dev/null and b/public/assets/img/jglby@2x.jpg differ diff --git a/public/assets/img/jiaguanzhu@2x.png b/public/assets/img/jiaguanzhu@2x.png new file mode 100644 index 0000000..9af7bd3 Binary files /dev/null and b/public/assets/img/jiaguanzhu@2x.png differ diff --git a/public/assets/img/jigou@2x(1).png b/public/assets/img/jigou@2x(1).png new file mode 100644 index 0000000..1bab04e Binary files /dev/null and b/public/assets/img/jigou@2x(1).png differ diff --git a/public/assets/img/jigou@2x.png b/public/assets/img/jigou@2x.png new file mode 100644 index 0000000..ae3cf57 Binary files /dev/null and b/public/assets/img/jigou@2x.png differ diff --git a/public/assets/img/lianxiren.png b/public/assets/img/lianxiren.png new file mode 100644 index 0000000..8960794 Binary files /dev/null and b/public/assets/img/lianxiren.png differ diff --git a/public/assets/img/lianxiwomen-bg@2x.png b/public/assets/img/lianxiwomen-bg@2x.png new file mode 100644 index 0000000..74a2e0a Binary files /dev/null and b/public/assets/img/lianxiwomen-bg@2x.png differ diff --git a/public/assets/img/logo-bai-1@2x.png b/public/assets/img/logo-bai-1@2x.png new file mode 100644 index 0000000..d2d49b6 Binary files /dev/null and b/public/assets/img/logo-bai-1@2x.png differ diff --git a/public/assets/img/logo-bai-2@2x.png b/public/assets/img/logo-bai-2@2x.png new file mode 100644 index 0000000..8667a26 Binary files /dev/null and b/public/assets/img/logo-bai-2@2x.png differ diff --git a/public/assets/img/logo2@2x.png b/public/assets/img/logo2@2x.png new file mode 100644 index 0000000..f4d15b1 Binary files /dev/null and b/public/assets/img/logo2@2x.png differ diff --git a/public/assets/img/logo3@2x.jpg b/public/assets/img/logo3@2x.jpg new file mode 100644 index 0000000..c63bde1 Binary files /dev/null and b/public/assets/img/logo3@2x.jpg differ diff --git a/public/assets/img/logo@2x.jpg b/public/assets/img/logo@2x.jpg new file mode 100644 index 0000000..1042ff9 Binary files /dev/null and b/public/assets/img/logo@2x.jpg differ diff --git a/public/assets/img/logo@2x.png b/public/assets/img/logo@2x.png new file mode 100644 index 0000000..af8bc1d Binary files /dev/null and b/public/assets/img/logo@2x.png differ diff --git a/public/assets/img/mobile/01@2x.png b/public/assets/img/mobile/01@2x.png new file mode 100644 index 0000000..f885acc Binary files /dev/null and b/public/assets/img/mobile/01@2x.png differ diff --git a/public/assets/img/mobile/02@2x.png b/public/assets/img/mobile/02@2x.png new file mode 100644 index 0000000..3a602bc Binary files /dev/null and b/public/assets/img/mobile/02@2x.png differ diff --git a/public/assets/img/mobile/03@2x.png b/public/assets/img/mobile/03@2x.png new file mode 100644 index 0000000..56d6cbb Binary files /dev/null and b/public/assets/img/mobile/03@2x.png differ diff --git a/public/assets/img/mobile/04@2x.png b/public/assets/img/mobile/04@2x.png new file mode 100644 index 0000000..a895cae Binary files /dev/null and b/public/assets/img/mobile/04@2x.png differ diff --git a/public/assets/img/mobile/banner1@2x.jpg b/public/assets/img/mobile/banner1@2x.jpg new file mode 100644 index 0000000..0507974 Binary files /dev/null and b/public/assets/img/mobile/banner1@2x.jpg differ diff --git a/public/assets/img/mobile/banner1@2x.png b/public/assets/img/mobile/banner1@2x.png new file mode 100644 index 0000000..9974ae1 Binary files /dev/null and b/public/assets/img/mobile/banner1@2x.png differ diff --git a/public/assets/img/mobile/banner2@2x.png b/public/assets/img/mobile/banner2@2x.png new file mode 100644 index 0000000..9ddce6e Binary files /dev/null and b/public/assets/img/mobile/banner2@2x.png differ diff --git a/public/assets/img/mobile/banner3@2x.png b/public/assets/img/mobile/banner3@2x.png new file mode 100644 index 0000000..4c5c01f Binary files /dev/null and b/public/assets/img/mobile/banner3@2x.png differ diff --git a/public/assets/img/mobile/banquanshengming@2x.png b/public/assets/img/mobile/banquanshengming@2x.png new file mode 100644 index 0000000..a4fd0a3 Binary files /dev/null and b/public/assets/img/mobile/banquanshengming@2x.png differ diff --git a/public/assets/img/mobile/bg.png b/public/assets/img/mobile/bg.png new file mode 100644 index 0000000..28cbffc Binary files /dev/null and b/public/assets/img/mobile/bg.png differ diff --git a/public/assets/img/mobile/bg1@2x.png b/public/assets/img/mobile/bg1@2x.png new file mode 100644 index 0000000..5b80640 Binary files /dev/null and b/public/assets/img/mobile/bg1@2x.png differ diff --git a/public/assets/img/mobile/bg2@2x.png b/public/assets/img/mobile/bg2@2x.png new file mode 100644 index 0000000..61f959a Binary files /dev/null and b/public/assets/img/mobile/bg2@2x.png differ diff --git a/public/assets/img/mobile/bg3@2x.png b/public/assets/img/mobile/bg3@2x.png new file mode 100644 index 0000000..3d4bb67 Binary files /dev/null and b/public/assets/img/mobile/bg3@2x.png differ diff --git a/public/assets/img/mobile/bg4@2x.png b/public/assets/img/mobile/bg4@2x.png new file mode 100644 index 0000000..79d4f2e Binary files /dev/null and b/public/assets/img/mobile/bg4@2x.png differ diff --git a/public/assets/img/mobile/bg5@2x.png b/public/assets/img/mobile/bg5@2x.png new file mode 100644 index 0000000..3abab14 Binary files /dev/null and b/public/assets/img/mobile/bg5@2x.png differ diff --git a/public/assets/img/mobile/bg6@2x.png b/public/assets/img/mobile/bg6@2x.png new file mode 100644 index 0000000..f9e758d Binary files /dev/null and b/public/assets/img/mobile/bg6@2x.png differ diff --git a/public/assets/img/mobile/caidan@2x.png b/public/assets/img/mobile/caidan@2x.png new file mode 100644 index 0000000..e9c3b59 Binary files /dev/null and b/public/assets/img/mobile/caidan@2x.png differ diff --git a/public/assets/img/mobile/dianping@2x.png b/public/assets/img/mobile/dianping@2x.png new file mode 100644 index 0000000..36605ec Binary files /dev/null and b/public/assets/img/mobile/dianping@2x.png differ diff --git a/public/assets/img/mobile/dianzan1@2x.png b/public/assets/img/mobile/dianzan1@2x.png new file mode 100644 index 0000000..45d150c Binary files /dev/null and b/public/assets/img/mobile/dianzan1@2x.png differ diff --git a/public/assets/img/mobile/dianzan2@2x.png b/public/assets/img/mobile/dianzan2@2x.png new file mode 100644 index 0000000..227e3fc Binary files /dev/null and b/public/assets/img/mobile/dianzan2@2x.png differ diff --git a/public/assets/img/mobile/dianzan3@2x.png b/public/assets/img/mobile/dianzan3@2x.png new file mode 100644 index 0000000..abd61da Binary files /dev/null and b/public/assets/img/mobile/dianzan3@2x.png differ diff --git a/public/assets/img/mobile/dianzan4@2x.png b/public/assets/img/mobile/dianzan4@2x.png new file mode 100644 index 0000000..12ded86 Binary files /dev/null and b/public/assets/img/mobile/dianzan4@2x.png differ diff --git a/public/assets/img/mobile/dier@2x.png b/public/assets/img/mobile/dier@2x.png new file mode 100644 index 0000000..5f14c58 Binary files /dev/null and b/public/assets/img/mobile/dier@2x.png differ diff --git a/public/assets/img/mobile/disan@2x.png b/public/assets/img/mobile/disan@2x.png new file mode 100644 index 0000000..fe775bb Binary files /dev/null and b/public/assets/img/mobile/disan@2x.png differ diff --git a/public/assets/img/mobile/diyi@2x.png b/public/assets/img/mobile/diyi@2x.png new file mode 100644 index 0000000..42703af Binary files /dev/null and b/public/assets/img/mobile/diyi@2x.png differ diff --git a/public/assets/img/mobile/drop@2x.png b/public/assets/img/mobile/drop@2x.png new file mode 100644 index 0000000..035f69a Binary files /dev/null and b/public/assets/img/mobile/drop@2x.png differ diff --git a/public/assets/img/mobile/dun1@2x.png b/public/assets/img/mobile/dun1@2x.png new file mode 100644 index 0000000..c520d03 Binary files /dev/null and b/public/assets/img/mobile/dun1@2x.png differ diff --git a/public/assets/img/mobile/earch@2x.png b/public/assets/img/mobile/earch@2x.png new file mode 100644 index 0000000..fbaeded Binary files /dev/null and b/public/assets/img/mobile/earch@2x.png differ diff --git a/public/assets/img/mobile/erweima.png b/public/assets/img/mobile/erweima.png new file mode 100644 index 0000000..28cbffc Binary files /dev/null and b/public/assets/img/mobile/erweima.png differ diff --git a/public/assets/img/mobile/fenxiang-da@2x.png b/public/assets/img/mobile/fenxiang-da@2x.png new file mode 100644 index 0000000..3827c88 Binary files /dev/null and b/public/assets/img/mobile/fenxiang-da@2x.png differ diff --git a/public/assets/img/mobile/fenxiang-xiao@2x.png b/public/assets/img/mobile/fenxiang-xiao@2x.png new file mode 100644 index 0000000..34c67a3 Binary files /dev/null and b/public/assets/img/mobile/fenxiang-xiao@2x.png differ diff --git a/public/assets/img/mobile/gaunyuwomen@2x.png b/public/assets/img/mobile/gaunyuwomen@2x.png new file mode 100644 index 0000000..facb6bb Binary files /dev/null and b/public/assets/img/mobile/gaunyuwomen@2x.png differ diff --git a/public/assets/img/mobile/gerenziliaop@2x.png b/public/assets/img/mobile/gerenziliaop@2x.png new file mode 100644 index 0000000..1ebb25e Binary files /dev/null and b/public/assets/img/mobile/gerenziliaop@2x.png differ diff --git a/public/assets/img/mobile/guanywomen-bg@2x.png b/public/assets/img/mobile/guanywomen-bg@2x.png new file mode 100644 index 0000000..a035677 Binary files /dev/null and b/public/assets/img/mobile/guanywomen-bg@2x.png differ diff --git a/public/assets/img/mobile/guanzhu@2x.png b/public/assets/img/mobile/guanzhu@2x.png new file mode 100644 index 0000000..3d57fc5 Binary files /dev/null and b/public/assets/img/mobile/guanzhu@2x.png differ diff --git a/public/assets/img/mobile/guanzhup@2x.png b/public/assets/img/mobile/guanzhup@2x.png new file mode 100644 index 0000000..99a609c Binary files /dev/null and b/public/assets/img/mobile/guanzhup@2x.png differ diff --git a/public/assets/img/mobile/gywm@2x.png b/public/assets/img/mobile/gywm@2x.png new file mode 100644 index 0000000..fb8326e Binary files /dev/null and b/public/assets/img/mobile/gywm@2x.png differ diff --git a/public/assets/img/mobile/icon-moren@2x.png b/public/assets/img/mobile/icon-moren@2x.png new file mode 100644 index 0000000..9a9da9f Binary files /dev/null and b/public/assets/img/mobile/icon-moren@2x.png differ diff --git a/public/assets/img/mobile/jiaguaunzhu@2x.png b/public/assets/img/mobile/jiaguaunzhu@2x.png new file mode 100644 index 0000000..afce21e Binary files /dev/null and b/public/assets/img/mobile/jiaguaunzhu@2x.png differ diff --git a/public/assets/img/mobile/jiantou@2x.png b/public/assets/img/mobile/jiantou@2x.png new file mode 100644 index 0000000..de7b7d7 Binary files /dev/null and b/public/assets/img/mobile/jiantou@2x.png differ diff --git a/public/assets/img/mobile/jigou1@2x.png b/public/assets/img/mobile/jigou1@2x.png new file mode 100644 index 0000000..056b3d8 Binary files /dev/null and b/public/assets/img/mobile/jigou1@2x.png differ diff --git a/public/assets/img/mobile/jigou2@2x.png b/public/assets/img/mobile/jigou2@2x.png new file mode 100644 index 0000000..cf2e84f Binary files /dev/null and b/public/assets/img/mobile/jigou2@2x.png differ diff --git a/public/assets/img/mobile/jingqingqidai-bg@2x.png b/public/assets/img/mobile/jingqingqidai-bg@2x.png new file mode 100644 index 0000000..895f260 Binary files /dev/null and b/public/assets/img/mobile/jingqingqidai-bg@2x.png differ diff --git a/public/assets/img/mobile/jingqingqidai@2x.png b/public/assets/img/mobile/jingqingqidai@2x.png new file mode 100644 index 0000000..10ea033 Binary files /dev/null and b/public/assets/img/mobile/jingqingqidai@2x.png differ diff --git a/public/assets/img/mobile/laixiwomen@2x.png b/public/assets/img/mobile/laixiwomen@2x.png new file mode 100644 index 0000000..8f6e6f4 Binary files /dev/null and b/public/assets/img/mobile/laixiwomen@2x.png differ diff --git a/public/assets/img/mobile/logo-2@2x.png b/public/assets/img/mobile/logo-2@2x.png new file mode 100644 index 0000000..e3595dd Binary files /dev/null and b/public/assets/img/mobile/logo-2@2x.png differ diff --git a/public/assets/img/mobile/logo3@2x.jpg b/public/assets/img/mobile/logo3@2x.jpg new file mode 100644 index 0000000..c63bde1 Binary files /dev/null and b/public/assets/img/mobile/logo3@2x.jpg differ diff --git a/public/assets/img/mobile/logo@2x.png b/public/assets/img/mobile/logo@2x.png new file mode 100644 index 0000000..f2a56d2 Binary files /dev/null and b/public/assets/img/mobile/logo@2x.png differ diff --git a/public/assets/img/mobile/paixu2@2x.png b/public/assets/img/mobile/paixu2@2x.png new file mode 100644 index 0000000..5a72c3a Binary files /dev/null and b/public/assets/img/mobile/paixu2@2x.png differ diff --git a/public/assets/img/mobile/pengyouquan@2x.png b/public/assets/img/mobile/pengyouquan@2x.png new file mode 100644 index 0000000..392317d Binary files /dev/null and b/public/assets/img/mobile/pengyouquan@2x.png differ diff --git a/public/assets/img/mobile/pingjiap@2x.png b/public/assets/img/mobile/pingjiap@2x.png new file mode 100644 index 0000000..fa17990 Binary files /dev/null and b/public/assets/img/mobile/pingjiap@2x.png differ diff --git a/public/assets/img/mobile/pmy@2x.png b/public/assets/img/mobile/pmy@2x.png new file mode 100644 index 0000000..03b8c0d Binary files /dev/null and b/public/assets/img/mobile/pmy@2x.png differ diff --git a/public/assets/img/mobile/qq@2x.png b/public/assets/img/mobile/qq@2x.png new file mode 100644 index 0000000..5fc3544 Binary files /dev/null and b/public/assets/img/mobile/qq@2x.png differ diff --git a/public/assets/img/mobile/sanjiao@2x.jpg b/public/assets/img/mobile/sanjiao@2x.jpg new file mode 100644 index 0000000..6bf066b Binary files /dev/null and b/public/assets/img/mobile/sanjiao@2x.jpg differ diff --git a/public/assets/img/mobile/sanjiao@2x.png b/public/assets/img/mobile/sanjiao@2x.png new file mode 100644 index 0000000..a7a10dc Binary files /dev/null and b/public/assets/img/mobile/sanjiao@2x.png differ diff --git a/public/assets/img/mobile/shanchu@2x.png b/public/assets/img/mobile/shanchu@2x.png new file mode 100644 index 0000000..4aefe54 Binary files /dev/null and b/public/assets/img/mobile/shanchu@2x.png differ diff --git a/public/assets/img/mobile/shoucang2@2x.png b/public/assets/img/mobile/shoucang2@2x.png new file mode 100644 index 0000000..3f5ed6f Binary files /dev/null and b/public/assets/img/mobile/shoucang2@2x.png differ diff --git a/public/assets/img/mobile/shoucang@2x.png b/public/assets/img/mobile/shoucang@2x.png new file mode 100644 index 0000000..7be16fc Binary files /dev/null and b/public/assets/img/mobile/shoucang@2x.png differ diff --git a/public/assets/img/mobile/shoucangp@2x.png b/public/assets/img/mobile/shoucangp@2x.png new file mode 100644 index 0000000..7520ea2 Binary files /dev/null and b/public/assets/img/mobile/shoucangp@2x.png differ diff --git a/public/assets/img/mobile/shouji1@2x.png b/public/assets/img/mobile/shouji1@2x.png new file mode 100644 index 0000000..d5007a0 Binary files /dev/null and b/public/assets/img/mobile/shouji1@2x.png differ diff --git a/public/assets/img/mobile/shouji2@2x.png b/public/assets/img/mobile/shouji2@2x.png new file mode 100644 index 0000000..abdf9dc Binary files /dev/null and b/public/assets/img/mobile/shouji2@2x.png differ diff --git a/public/assets/img/mobile/shouqi@2x.png b/public/assets/img/mobile/shouqi@2x.png new file mode 100644 index 0000000..e4829af Binary files /dev/null and b/public/assets/img/mobile/shouqi@2x.png differ diff --git a/public/assets/img/mobile/sl1@2x.png b/public/assets/img/mobile/sl1@2x.png new file mode 100644 index 0000000..2a6aff2 Binary files /dev/null and b/public/assets/img/mobile/sl1@2x.png differ diff --git a/public/assets/img/mobile/sl2@2x.png b/public/assets/img/mobile/sl2@2x.png new file mode 100644 index 0000000..a2c7666 Binary files /dev/null and b/public/assets/img/mobile/sl2@2x.png differ diff --git a/public/assets/img/mobile/sousuo-hui@2x.png b/public/assets/img/mobile/sousuo-hui@2x.png new file mode 100644 index 0000000..a22141f Binary files /dev/null and b/public/assets/img/mobile/sousuo-hui@2x.png differ diff --git a/public/assets/img/mobile/sousuo-lan@2x.png b/public/assets/img/mobile/sousuo-lan@2x.png new file mode 100644 index 0000000..d1ed08e Binary files /dev/null and b/public/assets/img/mobile/sousuo-lan@2x.png differ diff --git a/public/assets/img/mobile/specialist.png b/public/assets/img/mobile/specialist.png new file mode 100644 index 0000000..fc93588 Binary files /dev/null and b/public/assets/img/mobile/specialist.png differ diff --git a/public/assets/img/mobile/square_chose.png b/public/assets/img/mobile/square_chose.png new file mode 100644 index 0000000..dd55842 Binary files /dev/null and b/public/assets/img/mobile/square_chose.png differ diff --git a/public/assets/img/mobile/weallsmall.jpg b/public/assets/img/mobile/weallsmall.jpg new file mode 100644 index 0000000..fee0401 Binary files /dev/null and b/public/assets/img/mobile/weallsmall.jpg differ diff --git a/public/assets/img/mobile/wechat@2x.png b/public/assets/img/mobile/wechat@2x.png new file mode 100644 index 0000000..909961e Binary files /dev/null and b/public/assets/img/mobile/wechat@2x.png differ diff --git a/public/assets/img/mobile/weibo@2x.png b/public/assets/img/mobile/weibo@2x.png new file mode 100644 index 0000000..d98ac83 Binary files /dev/null and b/public/assets/img/mobile/weibo@2x.png differ diff --git a/public/assets/img/mobile/wushuju@2x.png b/public/assets/img/mobile/wushuju@2x.png new file mode 100644 index 0000000..9b81338 Binary files /dev/null and b/public/assets/img/mobile/wushuju@2x.png differ diff --git a/public/assets/img/mobile/xiala-lan1@2x.png b/public/assets/img/mobile/xiala-lan1@2x.png new file mode 100644 index 0000000..57ab25e Binary files /dev/null and b/public/assets/img/mobile/xiala-lan1@2x.png differ diff --git a/public/assets/img/mobile/xiayiye@2x.png b/public/assets/img/mobile/xiayiye@2x.png new file mode 100644 index 0000000..986ed57 Binary files /dev/null and b/public/assets/img/mobile/xiayiye@2x.png differ diff --git a/public/assets/img/mobile/xiazai@2x.png b/public/assets/img/mobile/xiazai@2x.png new file mode 100644 index 0000000..5390a03 Binary files /dev/null and b/public/assets/img/mobile/xiazai@2x.png differ diff --git a/public/assets/img/mobile/xin1@2x.png b/public/assets/img/mobile/xin1@2x.png new file mode 100644 index 0000000..f6a0e9d Binary files /dev/null and b/public/assets/img/mobile/xin1@2x.png differ diff --git a/public/assets/img/mobile/xl1@2x.png b/public/assets/img/mobile/xl1@2x.png new file mode 100644 index 0000000..8696e4a Binary files /dev/null and b/public/assets/img/mobile/xl1@2x.png differ diff --git a/public/assets/img/mobile/xl2@2x.png b/public/assets/img/mobile/xl2@2x.png new file mode 100644 index 0000000..84e4451 Binary files /dev/null and b/public/assets/img/mobile/xl2@2x.png differ diff --git a/public/assets/img/mobile/xl@2x.png b/public/assets/img/mobile/xl@2x.png new file mode 100644 index 0000000..8c22336 Binary files /dev/null and b/public/assets/img/mobile/xl@2x.png differ diff --git a/public/assets/img/mobile/yonghu1@2x.png b/public/assets/img/mobile/yonghu1@2x.png new file mode 100644 index 0000000..619916c Binary files /dev/null and b/public/assets/img/mobile/yonghu1@2x.png differ diff --git a/public/assets/img/mobile/yonghu2@2x.png b/public/assets/img/mobile/yonghu2@2x.png new file mode 100644 index 0000000..594e641 Binary files /dev/null and b/public/assets/img/mobile/yonghu2@2x.png differ diff --git a/public/assets/img/mobile/youxiang@2x.png b/public/assets/img/mobile/youxiang@2x.png new file mode 100644 index 0000000..66cb8cd Binary files /dev/null and b/public/assets/img/mobile/youxiang@2x.png differ diff --git a/public/assets/img/mobile/zdjg@2x.png b/public/assets/img/mobile/zdjg@2x.png new file mode 100644 index 0000000..12140a8 Binary files /dev/null and b/public/assets/img/mobile/zdjg@2x.png differ diff --git a/public/assets/img/mobile/zhankai@2x.png b/public/assets/img/mobile/zhankai@2x.png new file mode 100644 index 0000000..8ba5435 Binary files /dev/null and b/public/assets/img/mobile/zhankai@2x.png differ diff --git a/public/assets/img/mobile/zhedie@2x.png b/public/assets/img/mobile/zhedie@2x.png new file mode 100644 index 0000000..c54a1cd Binary files /dev/null and b/public/assets/img/mobile/zhedie@2x.png differ diff --git a/public/assets/img/mobile/zhiding2@2x.png b/public/assets/img/mobile/zhiding2@2x.png new file mode 100644 index 0000000..627b058 Binary files /dev/null and b/public/assets/img/mobile/zhiding2@2x.png differ diff --git a/public/assets/img/morenzhaopian@2x.png b/public/assets/img/morenzhaopian@2x.png new file mode 100644 index 0000000..f174703 Binary files /dev/null and b/public/assets/img/morenzhaopian@2x.png differ diff --git a/public/assets/img/nianfen@2x.png b/public/assets/img/nianfen@2x.png new file mode 100644 index 0000000..d1dbae7 Binary files /dev/null and b/public/assets/img/nianfen@2x.png differ diff --git a/public/assets/img/pengyouquan1@2x.png b/public/assets/img/pengyouquan1@2x.png new file mode 100644 index 0000000..b210212 Binary files /dev/null and b/public/assets/img/pengyouquan1@2x.png differ diff --git a/public/assets/img/pengyouquan2@2x.png b/public/assets/img/pengyouquan2@2x.png new file mode 100644 index 0000000..14fe5bc Binary files /dev/null and b/public/assets/img/pengyouquan2@2x.png differ diff --git a/public/assets/img/pingjia1@2x.png b/public/assets/img/pingjia1@2x.png new file mode 100644 index 0000000..4a2a591 Binary files /dev/null and b/public/assets/img/pingjia1@2x.png differ diff --git a/public/assets/img/pingjia2@2x.png b/public/assets/img/pingjia2@2x.png new file mode 100644 index 0000000..0d0daac Binary files /dev/null and b/public/assets/img/pingjia2@2x.png differ diff --git a/public/assets/img/pm@2x.png b/public/assets/img/pm@2x.png new file mode 100644 index 0000000..a3f38d1 Binary files /dev/null and b/public/assets/img/pm@2x.png differ diff --git a/public/assets/img/pmy.jpg b/public/assets/img/pmy.jpg new file mode 100644 index 0000000..d632e7f Binary files /dev/null and b/public/assets/img/pmy.jpg differ diff --git a/public/assets/img/qian@2x.png b/public/assets/img/qian@2x.png new file mode 100644 index 0000000..8789bf1 Binary files /dev/null and b/public/assets/img/qian@2x.png differ diff --git a/public/assets/img/qianwang-bai@2x.png b/public/assets/img/qianwang-bai@2x.png new file mode 100644 index 0000000..1aba551 Binary files /dev/null and b/public/assets/img/qianwang-bai@2x.png differ diff --git a/public/assets/img/qianwang@2x.png b/public/assets/img/qianwang@2x.png new file mode 100644 index 0000000..ed3387d Binary files /dev/null and b/public/assets/img/qianwang@2x.png differ diff --git a/public/assets/img/qq1@2x.png b/public/assets/img/qq1@2x.png new file mode 100644 index 0000000..25329a4 Binary files /dev/null and b/public/assets/img/qq1@2x.png differ diff --git a/public/assets/img/qq2@2x.png b/public/assets/img/qq2@2x.png new file mode 100644 index 0000000..3e03801 Binary files /dev/null and b/public/assets/img/qq2@2x.png differ diff --git a/public/assets/img/qq@2x.png b/public/assets/img/qq@2x.png new file mode 100644 index 0000000..25ad454 Binary files /dev/null and b/public/assets/img/qq@2x.png differ diff --git a/public/assets/img/quanbufenlei@2x.png b/public/assets/img/quanbufenlei@2x.png new file mode 100644 index 0000000..ecd59b2 Binary files /dev/null and b/public/assets/img/quanbufenlei@2x.png differ diff --git a/public/assets/img/shangyiye@2x.png b/public/assets/img/shangyiye@2x.png new file mode 100644 index 0000000..e91d747 Binary files /dev/null and b/public/assets/img/shangyiye@2x.png differ diff --git a/public/assets/img/shoucang1@2x.png b/public/assets/img/shoucang1@2x.png new file mode 100644 index 0000000..fc4c923 Binary files /dev/null and b/public/assets/img/shoucang1@2x.png differ diff --git a/public/assets/img/shoucang2@2x.png b/public/assets/img/shoucang2@2x.png new file mode 100644 index 0000000..c2ab1de Binary files /dev/null and b/public/assets/img/shoucang2@2x.png differ diff --git a/public/assets/img/shoucang@2x(1).png b/public/assets/img/shoucang@2x(1).png new file mode 100644 index 0000000..5da6d28 Binary files /dev/null and b/public/assets/img/shoucang@2x(1).png differ diff --git a/public/assets/img/shoucang@2x(2).png b/public/assets/img/shoucang@2x(2).png new file mode 100644 index 0000000..eb6536f Binary files /dev/null and b/public/assets/img/shoucang@2x(2).png differ diff --git a/public/assets/img/shoucang@2x.png b/public/assets/img/shoucang@2x.png new file mode 100644 index 0000000..5da6d28 Binary files /dev/null and b/public/assets/img/shoucang@2x.png differ diff --git a/public/assets/img/shouji1@2x.png b/public/assets/img/shouji1@2x.png new file mode 100644 index 0000000..abdf9dc Binary files /dev/null and b/public/assets/img/shouji1@2x.png differ diff --git a/public/assets/img/shouji2@2x.png b/public/assets/img/shouji2@2x.png new file mode 100644 index 0000000..d5007a0 Binary files /dev/null and b/public/assets/img/shouji2@2x.png differ diff --git a/public/assets/img/sl1@2x.png b/public/assets/img/sl1@2x.png new file mode 100644 index 0000000..5a72c3a Binary files /dev/null and b/public/assets/img/sl1@2x.png differ diff --git a/public/assets/img/sousuo@2x.png b/public/assets/img/sousuo@2x.png new file mode 100644 index 0000000..7d0b2e7 Binary files /dev/null and b/public/assets/img/sousuo@2x.png differ diff --git a/public/assets/img/specialist.png b/public/assets/img/specialist.png new file mode 100644 index 0000000..fc93588 Binary files /dev/null and b/public/assets/img/specialist.png differ diff --git a/public/assets/img/sq@2x.png b/public/assets/img/sq@2x.png new file mode 100644 index 0000000..fbb6af4 Binary files /dev/null and b/public/assets/img/sq@2x.png differ diff --git a/public/assets/img/square_chose.png b/public/assets/img/square_chose.png new file mode 100644 index 0000000..dd55842 Binary files /dev/null and b/public/assets/img/square_chose.png differ diff --git a/public/assets/img/sy-01@2x.jpg b/public/assets/img/sy-01@2x.jpg new file mode 100644 index 0000000..2457d92 Binary files /dev/null and b/public/assets/img/sy-01@2x.jpg differ diff --git a/public/assets/img/sy-02@2x.jpg b/public/assets/img/sy-02@2x.jpg new file mode 100644 index 0000000..ba0676c Binary files /dev/null and b/public/assets/img/sy-02@2x.jpg differ diff --git a/public/assets/img/sy-03@2x.jpg b/public/assets/img/sy-03@2x.jpg new file mode 100644 index 0000000..357e595 Binary files /dev/null and b/public/assets/img/sy-03@2x.jpg differ diff --git a/public/assets/img/sy-04@2x.jpg b/public/assets/img/sy-04@2x.jpg new file mode 100644 index 0000000..3d1c9a4 Binary files /dev/null and b/public/assets/img/sy-04@2x.jpg differ diff --git a/public/assets/img/touxiang@2x.png b/public/assets/img/touxiang@2x.png new file mode 100644 index 0000000..59af051 Binary files /dev/null and b/public/assets/img/touxiang@2x.png differ diff --git a/public/assets/img/transparent_logo.png b/public/assets/img/transparent_logo.png new file mode 100644 index 0000000..54d78d1 Binary files /dev/null and b/public/assets/img/transparent_logo.png differ diff --git a/public/assets/img/tx@2x.png b/public/assets/img/tx@2x.png new file mode 100644 index 0000000..ae0a75b Binary files /dev/null and b/public/assets/img/tx@2x.png differ diff --git a/public/assets/img/weallsmall.jpg b/public/assets/img/weallsmall.jpg new file mode 100644 index 0000000..fee0401 Binary files /dev/null and b/public/assets/img/weallsmall.jpg differ diff --git a/public/assets/img/weallsmall22.jpg b/public/assets/img/weallsmall22.jpg new file mode 100644 index 0000000..2663b6e Binary files /dev/null and b/public/assets/img/weallsmall22.jpg differ diff --git a/public/assets/img/weibo1@2x.png b/public/assets/img/weibo1@2x.png new file mode 100644 index 0000000..9d9d63e Binary files /dev/null and b/public/assets/img/weibo1@2x.png differ diff --git a/public/assets/img/weibo2@2x.png b/public/assets/img/weibo2@2x.png new file mode 100644 index 0000000..44ebf6b Binary files /dev/null and b/public/assets/img/weibo2@2x.png differ diff --git a/public/assets/img/weibo@2x.png b/public/assets/img/weibo@2x.png new file mode 100644 index 0000000..7c74a7c Binary files /dev/null and b/public/assets/img/weibo@2x.png differ diff --git a/public/assets/img/weiixin2@2x.png b/public/assets/img/weiixin2@2x.png new file mode 100644 index 0000000..34d01d8 Binary files /dev/null and b/public/assets/img/weiixin2@2x.png differ diff --git a/public/assets/img/weixin1@2x.png b/public/assets/img/weixin1@2x.png new file mode 100644 index 0000000..f7b27a0 Binary files /dev/null and b/public/assets/img/weixin1@2x.png differ diff --git a/public/assets/img/weixin2@2x.png b/public/assets/img/weixin2@2x.png new file mode 100644 index 0000000..3d176e6 Binary files /dev/null and b/public/assets/img/weixin2@2x.png differ diff --git a/public/assets/img/weixin@2x.png b/public/assets/img/weixin@2x.png new file mode 100644 index 0000000..051ff24 Binary files /dev/null and b/public/assets/img/weixin@2x.png differ diff --git a/public/assets/img/wenxian@2x.png b/public/assets/img/wenxian@2x.png new file mode 100644 index 0000000..cf20395 Binary files /dev/null and b/public/assets/img/wenxian@2x.png differ diff --git a/public/assets/img/wnwenxian@2x.png b/public/assets/img/wnwenxian@2x.png new file mode 100644 index 0000000..00704e0 Binary files /dev/null and b/public/assets/img/wnwenxian@2x.png differ diff --git a/public/assets/img/wushuju@2x.png b/public/assets/img/wushuju@2x.png new file mode 100644 index 0000000..9b81338 Binary files /dev/null and b/public/assets/img/wushuju@2x.png differ diff --git a/public/assets/img/xiayiye@2x.png b/public/assets/img/xiayiye@2x.png new file mode 100644 index 0000000..2f87e91 Binary files /dev/null and b/public/assets/img/xiayiye@2x.png differ diff --git a/public/assets/img/xiazai2@2x.png b/public/assets/img/xiazai2@2x.png new file mode 100644 index 0000000..67ae082 Binary files /dev/null and b/public/assets/img/xiazai2@2x.png differ diff --git a/public/assets/img/xiazai@2x.png b/public/assets/img/xiazai@2x.png new file mode 100644 index 0000000..da9de29 Binary files /dev/null and b/public/assets/img/xiazai@2x.png differ diff --git a/public/assets/img/xin1@2x.png b/public/assets/img/xin1@2x.png new file mode 100644 index 0000000..d2410c4 Binary files /dev/null and b/public/assets/img/xin1@2x.png differ diff --git a/public/assets/img/xin2@2x.png b/public/assets/img/xin2@2x.png new file mode 100644 index 0000000..300cfc9 Binary files /dev/null and b/public/assets/img/xin2@2x.png differ diff --git a/public/assets/img/xl@2x.png b/public/assets/img/xl@2x.png new file mode 100644 index 0000000..03a6da8 Binary files /dev/null and b/public/assets/img/xl@2x.png differ diff --git a/public/assets/img/xl@2x1.png b/public/assets/img/xl@2x1.png new file mode 100644 index 0000000..501ebcd Binary files /dev/null and b/public/assets/img/xl@2x1.png differ diff --git a/public/assets/img/xueke@2x(1).png b/public/assets/img/xueke@2x(1).png new file mode 100644 index 0000000..a5d9b0a Binary files /dev/null and b/public/assets/img/xueke@2x(1).png differ diff --git a/public/assets/img/xueke@2x.png b/public/assets/img/xueke@2x.png new file mode 100644 index 0000000..66bcf0f Binary files /dev/null and b/public/assets/img/xueke@2x.png differ diff --git a/public/assets/img/yiguanzhu@2x.png b/public/assets/img/yiguanzhu@2x.png new file mode 100644 index 0000000..e448dd6 Binary files /dev/null and b/public/assets/img/yiguanzhu@2x.png differ diff --git a/public/assets/img/youxiang@2x.png b/public/assets/img/youxiang@2x.png new file mode 100644 index 0000000..66cb8cd Binary files /dev/null and b/public/assets/img/youxiang@2x.png differ diff --git a/public/assets/img/youxinag@2x.png b/public/assets/img/youxinag@2x.png new file mode 100644 index 0000000..11d5040 Binary files /dev/null and b/public/assets/img/youxinag@2x.png differ diff --git a/public/assets/img/zazhi@2x(1).png b/public/assets/img/zazhi@2x(1).png new file mode 100644 index 0000000..cf8f442 Binary files /dev/null and b/public/assets/img/zazhi@2x(1).png differ diff --git a/public/assets/img/zazhi@2x.png b/public/assets/img/zazhi@2x.png new file mode 100644 index 0000000..37f3b34 Binary files /dev/null and b/public/assets/img/zazhi@2x.png differ diff --git a/public/assets/img/zhanghao1.png b/public/assets/img/zhanghao1.png new file mode 100644 index 0000000..a906cfb Binary files /dev/null and b/public/assets/img/zhanghao1.png differ diff --git a/public/assets/img/zhanghao2.png b/public/assets/img/zhanghao2.png new file mode 100644 index 0000000..b4c2b96 Binary files /dev/null and b/public/assets/img/zhanghao2.png differ diff --git a/public/assets/img/zhankai2@2x.png b/public/assets/img/zhankai2@2x.png new file mode 100644 index 0000000..21e05bb Binary files /dev/null and b/public/assets/img/zhankai2@2x.png differ diff --git a/public/assets/img/zhankai@2x(1).png b/public/assets/img/zhankai@2x(1).png new file mode 100644 index 0000000..0966bc9 Binary files /dev/null and b/public/assets/img/zhankai@2x(1).png differ diff --git a/public/assets/img/zhankai@2x.png b/public/assets/img/zhankai@2x.png new file mode 100644 index 0000000..f17472b Binary files /dev/null and b/public/assets/img/zhankai@2x.png differ diff --git a/public/assets/img/zhedie2@2x.png b/public/assets/img/zhedie2@2x.png new file mode 100644 index 0000000..57c8857 Binary files /dev/null and b/public/assets/img/zhedie2@2x.png differ diff --git a/public/assets/img/zhedie@2x(1).png b/public/assets/img/zhedie@2x(1).png new file mode 100644 index 0000000..c54a1cd Binary files /dev/null and b/public/assets/img/zhedie@2x(1).png differ diff --git a/public/assets/img/zhedie@2x.png b/public/assets/img/zhedie@2x.png new file mode 100644 index 0000000..c54a1cd Binary files /dev/null and b/public/assets/img/zhedie@2x.png differ diff --git a/public/assets/img/zhengxu@2x.png b/public/assets/img/zhengxu@2x.png new file mode 100644 index 0000000..0a8218b Binary files /dev/null and b/public/assets/img/zhengxu@2x.png differ diff --git a/public/assets/img/zhuangshi-you@2x.png b/public/assets/img/zhuangshi-you@2x.png new file mode 100644 index 0000000..bac7561 Binary files /dev/null and b/public/assets/img/zhuangshi-you@2x.png differ diff --git a/public/assets/img/zhuangshi-zuo@2x.png b/public/assets/img/zhuangshi-zuo@2x.png new file mode 100644 index 0000000..ccec443 Binary files /dev/null and b/public/assets/img/zhuangshi-zuo@2x.png differ diff --git a/public/assets/img/zk@2x.png b/public/assets/img/zk@2x.png new file mode 100644 index 0000000..42800c8 Binary files /dev/null and b/public/assets/img/zk@2x.png differ diff --git a/public/assets/img/微信图片_20220713112838.jpg b/public/assets/img/微信图片_20220713112838.jpg new file mode 100644 index 0000000..0391be5 Binary files /dev/null and b/public/assets/img/微信图片_20220713112838.jpg differ diff --git a/public/assets/img/编组 7@2x.png b/public/assets/img/编组 7@2x.png new file mode 100644 index 0000000..abfc5ff Binary files /dev/null and b/public/assets/img/编组 7@2x.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..53f88a9 Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/components/admin/RichTextEditor.jsx b/src/components/admin/RichTextEditor.jsx new file mode 100644 index 0000000..13c79fd --- /dev/null +++ b/src/components/admin/RichTextEditor.jsx @@ -0,0 +1,160 @@ +import { useEffect } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Placeholder from "@tiptap/extension-placeholder"; +import Underline from "@tiptap/extension-underline"; +import { Button, Space, Tooltip } from "antd"; +import { + BoldOutlined, + ItalicOutlined, + UnderlineOutlined, + StrikethroughOutlined, + OrderedListOutlined, + UnorderedListOutlined, + LinkOutlined, + UndoOutlined, + RedoOutlined, +} from "@ant-design/icons"; + +/** + * Tiptap 富文本编辑器,支持 Ant Design Form(value / onChange)。 + */ +export default function RichTextEditor({ + value = "", + onChange, + placeholder = "请输入内容…", + minHeight = 160, + disabled = false, +}) { + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: { levels: [2, 3] }, + }), + Underline, + Link.configure({ + openOnClick: false, + HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" }, + }), + Placeholder.configure({ placeholder }), + ], + content: value || "", + editable: !disabled, + onUpdate: ({ editor: ed }) => { + const html = ed.isEmpty ? "" : ed.getHTML(); + onChange?.(html); + }, + editorProps: { + attributes: { + class: "rich-text-editor__content outline-none px-3 py-2", + style: `min-height: ${minHeight}px`, + }, + }, + }); + + // 外部 value 变化时同步(编辑回填) + useEffect(() => { + if (!editor) return; + const current = editor.isEmpty ? "" : editor.getHTML(); + const next = value || ""; + if (current !== next) { + editor.commands.setContent(next, { emitUpdate: false }); + } + }, [value, editor]); + + useEffect(() => { + if (!editor) return; + editor.setEditable(!disabled); + }, [disabled, editor]); + + if (!editor) return null; + + const setLink = () => { + const prev = editor.getAttributes("link").href; + const url = window.prompt("链接地址", prev || "https://"); + if (url === null) return; + if (url === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + }; + + const btn = (active, onClick, icon, title) => ( + + + ); +} + +function getVisitorKey() { + try { + const keyName = "star_share_visitor"; + let key = window.localStorage.getItem(keyName); + if (!key) { + key = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `v_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + window.localStorage.setItem(keyName, key); + } + return key; + } catch { + return ""; + } +} + +/** + * 列表/详情共用:点赞、收藏、分享。 + * - 点赞/收藏:需会员登录,可切换;状态由父组件传入并回写 + * - 分享:自定义面板,无需登录 + */ +export default function ArticleEngageActions({ + article, + liked = false, + collected = false, + onUpdate, + showRead = true, + className, +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const memberToken = useSelector((s) => s.member?.token); + const [busy, setBusy] = useState(""); + const [shareOpen, setShareOpen] = useState(false); + const shareCountedRef = useRef(false); + + if (!article?.id) return null; + + const shareUrl = `${window.location.origin}/fingerpostDetail?id=${article.id}`; + const patch = (partial) => onUpdate?.(partial); + + const requireLogin = () => { + const redirect = `${location.pathname}${location.search}`; + navigate(`/login?redirect=${encodeURIComponent(redirect)}`); + }; + + const onLike = async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (busy) return; + if (!memberToken) { + requireLogin(); + return; + } + setBusy("like"); + try { + const res = await contentApi.toggleArticleLike(article.id); + patch({ + liked: Boolean(res?.liked), + like_num: res?.like_num ?? article.like_num, + }); + } catch (err) { + feedback(err); + const detail = String(err?.detail || ""); + if (detail.includes("登录") || detail.includes("认证")) { + requireLogin(); + } + } finally { + setBusy(""); + } + }; + + const onCollect = async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (busy) return; + if (!memberToken) { + requireLogin(); + return; + } + setBusy("collect"); + try { + const res = await contentApi.toggleArticleCollect(article.id); + patch({ + collected: Boolean(res?.collected), + collect_num: res?.collect_num ?? article.collect_num, + }); + } catch (err) { + feedback(err); + const detail = String(err?.detail || ""); + if (detail.includes("登录") || detail.includes("认证")) { + requireLogin(); + } + } finally { + setBusy(""); + } + }; + + const openShare = (e) => { + e.preventDefault(); + e.stopPropagation(); + shareCountedRef.current = false; + setShareOpen(true); + }; + + const recordShareOnce = async () => { + if (shareCountedRef.current || busy === "share") return; + shareCountedRef.current = true; + setBusy("share"); + try { + const res = await contentApi.recordArticleShare( + article.id, + getVisitorKey(), + ); + if (res?.share_num != null) patch({ share_num: res.share_num }); + } catch (err) { + shareCountedRef.current = false; + feedback(err); + } finally { + setBusy(""); + } + }; + + return ( +
e.stopPropagation()} + > + {showRead && ( + <> + + + {article.read_num ?? 0} + + {t("article.readNum")} + + + + )} + + + + + + + setShareOpen(false)} + title={article.title || ""} + url={shareUrl} + onShared={recordShareOnce} + /> +
+ ); +} diff --git a/src/components/site/ArticleShareDialog.jsx b/src/components/site/ArticleShareDialog.jsx new file mode 100644 index 0000000..32d1930 --- /dev/null +++ b/src/components/site/ArticleShareDialog.jsx @@ -0,0 +1,283 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +function isWechatBrowser() { + if (typeof navigator === "undefined") return false; + return /MicroMessenger/i.test(navigator.userAgent || ""); +} + +/** + * 前台自定义分享面板:复制链接 / 微博 / 微信。 + * 微信网页无法直接调起客户端,改为复制链接引导粘贴,或微信内提示右上角分享。 + */ +export default function ArticleShareDialog({ + open, + onClose, + title = "", + url = "", + onShared, +}) { + const { t } = useTranslation(); + const titleId = useId(); + const panelRef = useRef(null); + const [copied, setCopied] = useState(false); + const [channel, setChannel] = useState("link"); // link | wechat | weibo + const inWechat = isWechatBrowser(); + + useEffect(() => { + if (!open) { + setCopied(false); + setChannel("link"); + return; + } + const onKey = (e) => { + if (e.key === "Escape") onClose?.(); + }; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + document.removeEventListener("keydown", onKey); + }; + }, [open, onClose]); + + if (!open) return null; + + const copyLink = async (from = "copy") => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + } else { + const input = document.createElement("input"); + input.value = url; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + onShared?.(from); + } catch { + window.prompt(t("article.shareCopyPrompt"), url); + } + }; + + const shareWeibo = () => { + const wb = new URL("https://service.weibo.com/share/share.php"); + wb.searchParams.set("url", url); + wb.searchParams.set("title", title || url); + // 普通新标签打开,复用浏览器已登录的微博会话 + const a = document.createElement("a"); + a.href = wb.toString(); + a.target = "_blank"; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + onShared?.("weibo"); + }; + + const openWechat = () => { + setChannel("wechat"); + // 非微信内:进入即复制,方便直接去微信粘贴 + if (!inWechat) { + copyLink("wechat"); + } + }; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose?.(); + }} + > +
+
e.stopPropagation()} + > +
+
+

+ {t("article.shareTitle")} +

+ {title ? ( +

+ {title} +

+ ) : null} +
+ +
+ +
+
+ + { + setChannel("weibo"); + shareWeibo(); + }} + icon="/assets/img/weibo@2x.png" + label={t("article.shareWeibo")} + /> + { + setChannel("link"); + copyLink("copy"); + }} + icon="/assets/img/fenxiang@2x.png" + label={copied && channel === "link" ? t("article.shareCopied") : t("article.shareCopy")} + /> +
+
+ +
+ {channel === "wechat" ? ( +
+ {inWechat ? ( +
+ +

+ {t("article.shareWechatInApp")} +

+ +
+ ) : ( +
+
    +
  1. {t("article.shareWechatStep1")}
  2. +
  3. {t("article.shareWechatStep2")}
  4. +
  5. {t("article.shareWechatStep3")}
  6. +
+
+ e.target.select()} + /> + +
+

+ {t("article.shareWechatCopyTip")} +

+
+ )} +
+ ) : ( +
+ e.target.select()} + /> + +
+ )} +
+
+ +
, + document.body, + ); +} + +function ChannelButton({ active, onClick, icon, label }) { + return ( + + ); +} diff --git a/src/components/site/BrandLogo.jsx b/src/components/site/BrandLogo.jsx new file mode 100644 index 0000000..f641aea --- /dev/null +++ b/src/components/site/BrandLogo.jsx @@ -0,0 +1,68 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +/** + * 品牌标识:STAR 字标高度固定,副标用文字随语言切换,避免换整图时尺寸跳动。 + * variant: header(白底深蓝)| footer(深底白色) + */ +export default function BrandLogo({ variant = "header", className, to = "/index" }) { + const { i18n, t } = useTranslation(); + const isZh = i18n.language === "zh"; + const isFooter = variant === "footer"; + + const markSrc = isFooter + ? "/assets/img/logo-bai-1@2x.png" + : "/assets/img/logo@2x.png"; + + const content = ( + + STAR + {isZh ? ( + + 科学 + 透明 + 适用 + + ) : ( + + {t("brandTagline")} + + )} + + ); + + if (!to) return content; + return ( + + {content} + + ); +} diff --git a/src/components/site/DialCodeSelect.jsx b/src/components/site/DialCodeSelect.jsx new file mode 100644 index 0000000..5724a4d --- /dev/null +++ b/src/components/site/DialCodeSelect.jsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { DIAL_CODES, DEFAULT_DIAL_CODE } from "@/data/dialCodes"; + +function ChevronIcon({ open, className }) { + return ( + + + + ); +} + +function SearchIcon({ className }) { + return ( + + + + + ); +} + +function CheckIcon({ className }) { + return ( + + + + ); +} + +/** + * 国家/地区区号选择:可搜索、键盘可用,默认与手机号输入组合成一体。 + */ +export default function DialCodeSelect({ + value = DEFAULT_DIAL_CODE, + onChange, + className, + disabled = false, +}) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language === "zh"; + const rootRef = useRef(null); + const searchRef = useRef(null); + const listRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const selected = useMemo( + () => DIAL_CODES.find((item) => item.code === value) || DIAL_CODES[0], + [value], + ); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return DIAL_CODES; + return DIAL_CODES.filter((item) => { + const name = (isZh ? item.cn : item.en).toLowerCase(); + return ( + name.includes(q) || + item.code.toLowerCase().includes(q) || + item.code.replace("+", "").includes(q.replace("+", "")) || + item.ab.toLowerCase().includes(q) || + item.cn.includes(query.trim()) || + item.en.toLowerCase().includes(q) + ); + }); + }, [query, isZh]); + + useEffect(() => { + if (!open) return; + setQuery(""); + const idx = Math.max( + 0, + DIAL_CODES.findIndex((item) => item.code === value), + ); + setActiveIndex(idx); + const timer = window.setTimeout(() => { + searchRef.current?.focus(); + listRef.current + ?.querySelector(`[data-index="${idx}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, 0); + return () => window.clearTimeout(timer); + }, [open, value]); + + useEffect(() => { + if (!open) return; + const onDoc = (e) => { + if (!rootRef.current?.contains(e.target)) setOpen(false); + }; + const onKey = (e) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const pick = (item) => { + onChange?.(item.code); + setOpen(false); + }; + + const onTriggerKeyDown = (e) => { + if (disabled) return; + if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen(true); + } + }; + + const onListKeyDown = (e) => { + if (!filtered.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => { + const next = Math.min(filtered.length - 1, i + 1); + listRef.current + ?.querySelector(`[data-index="${next}"]`) + ?.scrollIntoView({ block: "nearest" }); + return next; + }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => { + const next = Math.max(0, i - 1); + listRef.current + ?.querySelector(`[data-index="${next}"]`) + ?.scrollIntoView({ block: "nearest" }); + return next; + }); + } else if (e.key === "Enter") { + e.preventDefault(); + pick(filtered[activeIndex]); + } + }; + + return ( +
+ + + {open && ( +
+
+
+ + { + setQuery(e.target.value); + setActiveIndex(0); + }} + onKeyDown={onListKeyDown} + placeholder={t("member.dialSearch")} + className="h-full w-full border-0 bg-transparent text-sm outline-none placeholder:text-[#aaa]" + /> +
+
+ +
    + {filtered.length === 0 ? ( +
  • + {t("member.dialEmpty")} +
  • + ) : ( + filtered.map((item, index) => { + const active = index === activeIndex; + const selectedItem = item.code === value; + return ( +
  • + +
  • + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/src/components/site/EmptyState.jsx b/src/components/site/EmptyState.jsx new file mode 100644 index 0000000..e3c9d30 --- /dev/null +++ b/src/components/site/EmptyState.jsx @@ -0,0 +1,15 @@ +import { useTranslation } from "react-i18next"; + +export default function EmptyState({ text }) { + const { t } = useTranslation(); + return ( +
+ +
{text || t("search.empty")}
+
+ ); +} diff --git a/src/components/site/HomeCarousel.jsx b/src/components/site/HomeCarousel.jsx new file mode 100644 index 0000000..803884a --- /dev/null +++ b/src/components/site/HomeCarousel.jsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { FALLBACK_BANNERS } from "@/config/nav"; + +/** + * 对齐国内站 el-carousel:全宽 32vh,淡入切换 + 圆点。 + * 默认使用国内站拷贝的 banner1/2/3 原图。 + */ +export default function HomeCarousel({ banners }) { + const slides = + Array.isArray(banners) && banners.length > 0 ? banners : FALLBACK_BANNERS; + const [index, setIndex] = useState(0); + + useEffect(() => { + setIndex(0); + }, [slides.length]); + + useEffect(() => { + if (slides.length <= 1) return undefined; + const timer = setInterval(() => { + setIndex((i) => (i + 1) % slides.length); + }, 8000); + return () => clearInterval(timer); + }, [slides.length]); + + return ( +
+ {slides.map((item, i) => { + const active = i === index; + const img = ( + {item.title + ); + const body = item.link_url ? ( + item.link_url.startsWith("http") ? ( + + {img} + + ) : ( + + {img} + + ) + ) : ( +
{img}
+ ); + + return ( +
+ {body} +
+ ); + })} + + {slides.length > 1 && ( +
+ {slides.map((b, i) => ( +
+ )} +
+ ); +} diff --git a/src/components/site/PageBanner.jsx b/src/components/site/PageBanner.jsx new file mode 100644 index 0000000..10d9a6f --- /dev/null +++ b/src/components/site/PageBanner.jsx @@ -0,0 +1,17 @@ +/** 内页顶图。不传 height 时按原图比例铺满宽度(对齐国内站 specialist.png)。 */ +export default function PageBanner({ src, alt = "", height }) { + if (!height) { + return ( + {alt} + ); + } + return ( +
+ {alt} +
+ ); +} diff --git a/src/components/site/SectionTitle.jsx b/src/components/site/SectionTitle.jsx new file mode 100644 index 0000000..20eb136 --- /dev/null +++ b/src/components/site/SectionTitle.jsx @@ -0,0 +1,30 @@ +import { Link } from "react-router-dom"; + +/** 对齐国内站:两侧装饰图 + 居中标题 */ +export default function SectionTitle({ title, to }) { + const text = ( + {title} + ); + + return ( +
+ + {to ? ( + + {text} + + ) : ( + text + )} + +
+ ); +} diff --git a/src/components/site/StarRating.jsx b/src/components/site/StarRating.jsx new file mode 100644 index 0000000..f6175f5 --- /dev/null +++ b/src/components/site/StarRating.jsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; +import { formatStarGlyphs } from "@/utils/starLevel"; + +/** 前台星级展示:只显示星标,不展示原始得分 */ +export default function StarRating({ + level, + className, + empty = "-", + max = 5, +}) { + if (level == null || level === "" || Number.isNaN(Number(level))) { + return {empty}; + } + const glyphs = formatStarGlyphs(level, max); + return ( + + {glyphs} + + ); +} diff --git a/src/components/site/YearSelect.jsx b/src/components/site/YearSelect.jsx new file mode 100644 index 0000000..e2d63a0 --- /dev/null +++ b/src/components/site/YearSelect.jsx @@ -0,0 +1,29 @@ +import { Select } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +/** 年份下拉;years 来自 facets,空数组时仅显示 All */ +export default function YearSelect({ + value, + onChange, + years = [], + className = "", + variant = "onDark", +}) { + return ( + + ); +} diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx new file mode 100644 index 0000000..910b3da --- /dev/null +++ b/src/components/ui/button.jsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import { cva } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-star-primary text-white hover:bg-[#12154a]", + outline: "border border-white text-white hover:bg-white/10", + ghost: "hover:bg-black/5 text-[#333]", + link: "text-star-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 px-3 text-xs", + lg: "h-10 px-6", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export const Button = forwardRef( + ({ className, variant, size, ...props }, ref) => ( + + + ); + }) + )} + +
, + document.body, + ) + : null; + + return ( +
+ {/* 保留隐藏 native 字段,便于表单序列化 */} + {name ? ( + + ) : null} + + + + {panel} +
+ ); + }, +); +Select.displayName = "Select"; diff --git a/src/config/columnTemplates.js b/src/config/columnTemplates.js new file mode 100644 index 0000000..5b73af0 --- /dev/null +++ b/src/config/columnTemplates.js @@ -0,0 +1,154 @@ +/** + * 栏目「页面模板」配置,对齐 star-guideline-api application-config.yml + * templates-tree + templates;url 按本站路由补全,便于选模板时回填 path。 + */ + +export const COLUMN_TEMPLATE_TREE = [ + { + id: 1, + name: "排名页模板", + children: [ + { + id: 1, + name: "指南共识排名页", + url: "/guideRanking", + description: "按年份/类型展示指南与共识 STAR 排名。", + }, + { + id: 2, + name: "杂志排名页", + url: "/magazineRanking", + description: "发布杂志维度的排名列表。", + }, + { + id: 3, + name: "机构排名页", + url: "/organizationRanking", + description: "制订机构维度的排名列表。", + }, + ], + }, + { + id: 2, + name: "列表页模板", + children: [ + { + id: 4, + name: "指南共识列表页", + url: "/guidelineAndConsensus", + description: "文献数据库检索与列表。", + }, + { + id: 5, + name: "专家列表页", + url: "/specialist", + description: "评级工作组 / 专家展示。", + }, + { + id: 6, + name: "机构列表页", + url: "/organization", + description: "制订机构列表与筛选。", + }, + ], + }, + { + id: 3, + name: "其它模板", + children: [ + { + id: 7, + name: "介绍型通用模板", + url: "", + description: "通用介绍页骨架,需栏目自行填写路径与注释内容。", + }, + { + id: 8, + name: "关于我们", + url: "/about", + description: "站点关于我们页。", + }, + { + id: 9, + name: "版权申明", + url: "/statement", + description: "版权与声明页。", + }, + { + id: 10, + name: "外部链接", + url: "", + description: "跳转站外链接,无固定前台模板页。", + }, + { + id: 11, + name: "联系我们", + url: "/relation", + description: "联系方式页。", + }, + ], + }, + { + id: 4, + name: "全部排名列表页", + children: [ + { + id: 12, + name: "全部排名列表页", + url: "/indexRanking", + description: "综合排名入口页。", + }, + ], + }, +]; + +/** 可预览的扁平模板列表(含分组信息) */ +export function listTemplatesFlat() { + return COLUMN_TEMPLATE_TREE.flatMap((group) => + group.children.map((t) => ({ + ...t, + groupId: group.id, + groupName: group.name, + label: `${group.name} / ${t.name}`, + })) + ); +} + +/** Cascader 选项(含「无」) */ +export const COLUMN_TEMPLATE_OPTIONS = [ + { value: "", label: "无" }, + ...COLUMN_TEMPLATE_TREE.map((group) => ({ + value: group.id, + label: group.name, + children: group.children.map((t) => ({ + value: t.id, + label: t.name, + })), + })), +]; + +const FLAT = COLUMN_TEMPLATE_TREE.flatMap((group) => + group.children.map((t) => ({ + ...t, + groupId: group.id, + groupName: group.name, + label: `${group.name} / ${t.name}`, + })) +); + +export function getTemplateById(templateId) { + if (templateId == null || templateId === "" || templateId === 0) return null; + return FLAT.find((t) => t.id === Number(templateId)) || null; +} + +export function getTemplateLabel(templateId) { + const t = getTemplateById(templateId); + return t ? t.label : "—"; +} + +/** Cascader 回显路径:[groupId, templateId] */ +export function templateCascaderValue(templateId) { + const t = getTemplateById(templateId); + if (!t) return []; + return [t.groupId, t.id]; +} diff --git a/src/config/nav.js b/src/config/nav.js new file mode 100644 index 0000000..4255986 --- /dev/null +++ b/src/config/nav.js @@ -0,0 +1,89 @@ +/** + * 对齐线上最新站 www.star-guidelines.cn 顶栏导航。 + */ +export const TOP_NAV = [ + { key: "home", to: "/index" }, + { + key: "ranking", + to: "/guideRanking", + children: [ + { key: "chinaGuideRanking", to: "/guideRanking?type=1" }, + { key: "chinaConsensusRanking", to: "/guideRanking?type=2" }, + { key: "tcmGuideRanking", to: "/guideRanking?type=1&scope=tcm" }, + { key: "tcmConsensusRanking", to: "/guideRanking?type=2&scope=tcm" }, + { key: "orgRanking", to: "/organizationRanking" }, + { key: "magazineRanking", to: "/magazineRanking" }, + ], + }, + { key: "database", to: "/guidelineAndConsensus" }, + { key: "organization", to: "/organization" }, + { key: "workgroup", to: "/specialist" }, + { + key: "about", + to: "/about", + children: [ + { key: "about", to: "/about" }, + { key: "statement", to: "/statement" }, + { key: "contact", to: "/relation" }, + ], + }, +]; + +export const FOOTER_NAV = [ + { + titleKey: "about", + links: [ + { key: "about", to: "/about" }, + { key: "statement", to: "/statement" }, + { key: "contact", to: "/relation" }, + ], + }, + { + titleKey: "ranking", + links: [ + { key: "chinaGuideRanking", to: "/guideRanking?type=1" }, + { key: "orgRanking", to: "/organizationRanking" }, + { key: "magazineRanking", to: "/magazineRanking" }, + ], + }, + { + titleKey: "database", + links: [ + { key: "database", to: "/guidelineAndConsensus" }, + { key: "organization", to: "/organization" }, + { key: "workgroup", to: "/specialist" }, + { key: "news", to: "/newsList" }, + ], + }, +]; + +/** 线上 COS 轮播原图(已下载到本地) */ +export const FALLBACK_BANNERS = [ + { + id: "live-1", + image_url: "/assets/img/carousel/jishitang.png", + link_url: "/about", + title: "积石堂", + }, + { + id: "live-2", + image_url: "/assets/img/carousel/yuanshi.png", + link_url: "/about", + title: "院士", + }, + { + id: "live-3", + image_url: "/assets/img/carousel/yuzhong.jpg", + link_url: "/about", + title: "榆中", + }, + { + id: "live-4", + image_url: "/assets/img/carousel/landa.jpg", + link_url: "/about", + title: "兰大", + }, +]; + +/** 固定头总高度:顶栏50 + logo行80 + 导航40 */ +export const HEADER_OFFSET = 170; diff --git a/src/config/pageGuide.js b/src/config/pageGuide.js new file mode 100644 index 0000000..824045f --- /dev/null +++ b/src/config/pageGuide.js @@ -0,0 +1,362 @@ +/** + * 前台页面线框导览配置。 + * regions.area:CSS grid-area 名;各页 layout.areas 定义网格模板。 + */ + +const HEADER_REGIONS = [ + { + id: "header-nav", + label: "顶栏导航", + adminPath: "/admin/columns", + adminLabel: "栏目管理", + area: "header", + }, +]; + +const FOOTER_REGIONS = [ + { + id: "footer-about", + label: "页脚 · 关于", + adminPath: "/admin/about", + adminLabel: "关于我们", + area: "footerAbout", + }, + { + id: "footer-statement", + label: "页脚 · 声明", + adminPath: "/admin/copyright", + adminLabel: "版权声明", + area: "footerStatement", + }, + { + id: "footer-contact", + label: "页脚 · 联系", + adminPath: "/admin/contact", + adminLabel: "联系我们", + area: "footerContact", + }, + { + id: "footer-links", + label: "页脚 · 外链", + adminPath: "/admin/links", + adminLabel: "外部链接", + area: "footerLinks", + }, +]; + +/** 带顶栏+页脚的标准壳层网格 */ +const SHELL_LAYOUT = { + columns: "1fr 1fr 1fr 1fr", + rows: "48px minmax(280px, 1fr) 72px", + areas: ` + "header header header header" + "main main main main" + "footerAbout footerStatement footerContact footerLinks" + `, +}; + +function withShell(mainRegions, extraLayout = {}) { + return { + layout: { ...SHELL_LAYOUT, ...extraLayout }, + regions: [...HEADER_REGIONS, ...mainRegions, ...FOOTER_REGIONS], + }; +} + +export const PAGE_GUIDE_PAGES = [ + { + key: "home", + label: "首页", + publicPath: "/index", + ...withShell( + [ + { + id: "home-banner", + label: "首页轮播", + adminPath: "/admin/banners", + adminLabel: "首页轮播", + area: "banner", + }, + { + id: "home-news", + label: "新闻动态", + adminPath: "/admin/news", + adminLabel: "新闻管理", + area: "news", + }, + { + id: "home-publications", + label: "资源中心", + adminPath: "/admin/publications", + adminLabel: "资源中心", + area: "pubs", + }, + { + id: "home-reports", + label: "年度报告", + adminPath: "/admin/reports", + adminLabel: "报告管理", + area: "reports", + }, + ], + { + rows: "48px 120px minmax(160px, 1fr) 100px 72px", + areas: ` + "header header header header" + "banner banner banner banner" + "news news pubs pubs" + "reports reports reports reports" + "footerAbout footerStatement footerContact footerLinks" + `, + } + ), + }, + { + key: "articles", + label: "文献数据库", + publicPath: "/guidelineAndConsensus", + ...withShell( + [ + { + id: "articles-filter-subject", + label: "学科筛选", + adminPath: "/admin/subjects", + adminLabel: "学科管理", + area: "filter", + }, + { + id: "articles-filter-organize", + label: "机构筛选", + adminPath: "/admin/organizes", + adminLabel: "机构管理", + area: "filter", + }, + { + id: "articles-filter-magazine", + label: "杂志筛选", + adminPath: "/admin/magazines", + adminLabel: "杂志管理", + area: "filter", + }, + { + id: "articles-list", + label: "文献列表", + adminPath: "/admin/articles", + adminLabel: "文献管理", + area: "list", + }, + ], + { + rows: "48px 56px minmax(240px, 1fr) 72px", + columns: "200px 1fr 1fr 1fr", + areas: ` + "header header header header" + "filter list list list" + "filter list list list" + "footerAbout footerStatement footerContact footerLinks" + `, + } + ), + }, + { + key: "ranking", + label: "排行榜", + publicPath: "/guideRanking", + ...withShell( + [ + { + id: "rank-articles", + label: "文献排行", + adminPath: "/admin/articles", + adminLabel: "文献管理(评分)", + area: "rankA", + }, + { + id: "rank-orgs", + label: "机构排行", + adminPath: "/admin/organizes", + adminLabel: "机构管理", + area: "rankB", + }, + { + id: "rank-mags", + label: "杂志排行", + adminPath: "/admin/magazines", + adminLabel: "杂志管理", + area: "rankC", + }, + ], + { + rows: "48px minmax(240px, 1fr) 72px", + columns: "1fr 1fr 1fr 1fr", + areas: ` + "header header header header" + "rankA rankB rankC rankC" + "footerAbout footerStatement footerContact footerLinks" + `, + } + ), + }, + { + key: "organizes", + label: "机构", + publicPath: "/organization", + ...withShell( + [ + { + id: "org-cats", + label: "机构类别", + adminPath: "/admin/organize-categories", + adminLabel: "机构类别", + area: "cats", + }, + { + id: "org-list", + label: "机构列表", + adminPath: "/admin/organizes", + adminLabel: "机构管理", + area: "list", + }, + ], + { + rows: "48px 56px minmax(240px, 1fr) 72px", + columns: "200px 1fr 1fr 1fr", + areas: ` + "header header header header" + "cats list list list" + "cats list list list" + "footerAbout footerStatement footerContact footerLinks" + `, + } + ), + }, + { + key: "specialist", + label: "工作组 / 专家", + publicPath: "/specialist", + ...withShell( + [ + { + id: "spec-groups", + label: "工作组", + adminPath: "/admin/work-groups", + adminLabel: "工作组树", + area: "groups", + }, + { + id: "spec-terms", + label: "届次", + adminPath: "/admin/work-group-terms", + adminLabel: "工作组届次", + area: "terms", + }, + { + id: "spec-professors", + label: "专家列表", + adminPath: "/admin/professors", + adminLabel: "专家管理", + area: "professors", + }, + { + id: "spec-specialties", + label: "专科", + adminPath: "/admin/specialties", + adminLabel: "专科列表", + area: "specialties", + }, + ], + { + rows: "48px minmax(200px, 1fr) minmax(160px, 1fr) 72px", + areas: ` + "header header header header" + "groups groups terms terms" + "professors professors specialties specialties" + "footerAbout footerStatement footerContact footerLinks" + `, + } + ), + }, + { + key: "about", + label: "关于我们", + publicPath: "/about", + ...withShell( + [ + { + id: "about-body", + label: "关于我们正文", + adminPath: "/admin/about", + adminLabel: "关于我们", + area: "main", + }, + ] + ), + }, + { + key: "statement", + label: "版权声明", + publicPath: "/statement", + ...withShell( + [ + { + id: "statement-body", + label: "版权声明正文", + adminPath: "/admin/copyright", + adminLabel: "版权声明", + area: "main", + }, + ] + ), + }, + { + key: "contact", + label: "联系我们", + publicPath: "/relation", + ...withShell( + [ + { + id: "contact-body", + label: "联系我们正文", + adminPath: "/admin/contact", + adminLabel: "联系我们", + area: "main", + }, + ] + ), + }, + { + key: "news", + label: "新闻列表", + publicPath: "/newsList", + ...withShell( + [ + { + id: "news-list", + label: "新闻列表", + adminPath: "/admin/news", + adminLabel: "新闻管理", + area: "main", + }, + ] + ), + }, + { + key: "publications", + label: "资源中心", + publicPath: "/publication", + ...withShell( + [ + { + id: "pub-list", + label: "出版物列表", + adminPath: "/admin/publications", + adminLabel: "资源中心", + area: "main", + }, + ] + ), + }, +]; + +export function getPageGuideByKey(key) { + return PAGE_GUIDE_PAGES.find((p) => p.key === key) || PAGE_GUIDE_PAGES[0]; +} diff --git a/src/content/privacyPolicy.js b/src/content/privacyPolicy.js new file mode 100644 index 0000000..5f4500a --- /dev/null +++ b/src/content/privacyPolicy.js @@ -0,0 +1,4 @@ +/** 自 star-guideline-ui 迁入,正文保持原站表述 */ +export const title = "《STAR隐私政策》"; + +export const body = "STAR网站是由兰州大学健康数据科学研究院(以下简称“我们”)提供的信息技术服务产品,我们一贯尊重并保护使用者的隐私。我们的隐私政策适用于我们所提供的所有服务。为说明我们会如何收集、使用和存储你的个人信息及你享有何种权利,我们将通过本政策向你阐述相关事宜,其中要点如下:本隐私权政策属于STAR网络服务使用协议不可分割的一部分。请注意STAR网站会不时地检查其隐私措施,因此有关的措施会随之变化,当条款发生变更时,我们会在你登录及版本更新时以推送通知、弹窗的形式向你展示变更后的条款,如果你继续使用本产品即视为同意我们按照更新后的条款收集、使用、存储你的隐私信息。因此,我们恳请您定期光顾本页以确保对我们隐私政策最新版本始终保持了解\n\n1. 我们收集的范围\n\n在你使用STAR网站的过程中,我们会按照如下方式收集你在使用服务时主动提供或因为使用服务而产生的信息,用以向你提供服务、优化我们的服务以及保障你的帐号安全:\n1.1 当你注册STAR网站时,我们会收集你的机号码或邮箱,收集这些信息是为了帮助你完成注册,保护你帐号的安全。手机号码和邮箱属于敏感信息,收集此类信息是为了满足相关法律法规的网络实名制要求。若你不提供这类信息,你可能无法正常使用我们的服务。你还可以根据自身需求选择填写用户名、头像等信息。\n1.2 当你使用通讯录功能时,我们会对你的通讯录信息进行不可逆加密,并仅收集加密后的信息。上述信息属于敏感信息,拒绝提供该信息仅会使你无法使用上述功能,但不影响你正常使用微信的其他功能。\n1.3 当你使用搜索等功能时,我们会收集你的搜索记录、阅读记录和访问时间,目的是为了向你提供更加精准和个性化的服务和内容。\n另外,根据相关法律法规及国家标准,以下情形中,我们可能会收集、使用你的相关个人信息无需征求你的授权同意:\n1) 与国家安全、国防安全等国家利益直接相关的;与公共安全、公共卫生、公众知情等重大公共利益直接相关的;\n2) 与犯罪侦查、起诉、审判和判决执行等直接相关的;\n3) 出于维护你或其他个人的生命、财产、声誉等重大合法权益但又很难得到本人同意的;\n4) 所收集的个人信息是你自行向社会公众公开的;\n5) 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道;\n6) 根据你要求签订和履行合同所必需的;\n7) 用于维护所提供的产品或服务的安全稳定运行所必需的,例如发现、处置产品或服务的故障;\n8) 为开展合法的新闻报道所必需的;\n9) 出于公共利益开展统计或学术研究所必要,且其对外提供学术研究或描述的结果时,对结果中所包含的个人信息进行去标识化处理的;\n10) 法律法规规定的其他情形。\n请你理解,我们向你提供的功能和服务是不断更新和发展的,如果某一功能或服务未在前述说明中且收集了你的信息,我们会通过页面提示、交互流程、网站公告等方式另行向你说明信息收集的内容、范围和目的,以征得你的同意。\n请你注意,如为业务发展需要从第三方间接获取你个人信息,我们承诺将按照合法途径获取且严格遵守相关法律法规的规定,并督促第三方保障其提供的信息的合法性。\n\n2. 信息使用\n\n我们努力为用户的信息安全提供保障,以防止信息的丢失、不当使用、未经授权访问或披露。我们将在合理的安全水平内使用各种安全保护措施以保障信息的安全。若发生个人信息泄露等安全事件,我们会启动应急预案,阻止安全事件扩大,并以推送通知、公告等形式告知你。\n我们可能将通过某些功能所收集的信息用于我们的其他服务。例如,我们可能将你在使用我们某一功能或服务时我们收集的信息,在另一功能或服务中用于向你提供特定内容,包括但不限于对你阅读过的内容进行信息安全类提示、基于特征标签进行间接人群画像并提供更加精准和个性化的服务和内容等。如我们使用你的个人信息,超出了与收集时所声称的目的及具有直接或合理关联的范围,我们将在使用你的个人信息前,再次向你告知并征得你的明示同意。\n\n3. 信息披露\n\n我们不会对外公开披露其收集的个人信息,如必须公开披露时,我们会向你告知此次公开披露的目的、披露信息的类型及可能涉及的敏感信息,并征得你的明示同意。\n另外,根据相关法律法规及国家标准,以下情形中,我们可能会共享、转让、公开披露个人信息无需事先征得个人信息主体的授权同意:\n1) 与国家安全、国防安全直接相关的;\n2) 与公共安全、公共卫生、重大公共利益直接相关的;\n3) 与犯罪侦查、起诉、审判和判决执行等直接相关的;\n4) 出于维护个人信息主体或其他个人的生命、财产等重大合法权益但又很难得到本人同意的;\n5) 个人信息主体自行向社会公众公开的个人信息;\n6) 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。\n\n4. 信息存储和交换\n\nSTAR网站收集的有关您的信息和资料将保存在STAR网站及(或)其关联公司的服务器上,这些信息和资料可能传送至您所在国家、地区或STAR网站收集信息和资料所在地的境外并在境外被访问、存储和展示。\n\n5. Cookie的使用\n\n5.1 在您未拒绝接受cookies的情况,STAR网站会在您的计算机上设定或取用cookies,以便您能登录或使用依赖于cookies的STAR网站平台服务或功能。STAR网站使用cookies可为您提供更加周到的个性化服务,包括推广服务。\n5.2 您有权选择接受或拒绝接受cookies。您可以通过修改浏览器设置的方式拒绝接受cookies。但如果您选择拒绝接受cookies,则您可能无法登录或使用依赖于cookies的STAR网络服务或功能。\n5.3通过STAR网站所设cookies所取得的有关信息,将适用本政策。\n\n6. 信息安全\n\nSTAR帐号均有安全保护功能,请妥善保管您的用户名及密码信息。STAR网站将通过对用户密码进行加密等安全措施确保您的信息不丢失,不被滥用和变造。尽管有前述安全措施,但同时也请您注意在信息网络上不存在“完善的安全措施”。\n\n7. 免责\n\n下列情况时本网站无需承担任何责任:\n1) 由于您将用户密码告知他人或与他人共享注册帐户,由此导致的任何个人资料泄露。\n2) 任何由于黑客攻击、计算机病毒侵入或发作、因政府管制而造成的暂时性关闭等影响网络正常经营之不可抗力而造成的个人资料泄露、丢失、被盗用或被篡改等。\n3) 由于与本网站链接的其它网站所造成之个人资料泄露及由此而导致的任何法律争议和后果。\n\n8. 外部链接\n\n我们非常重视对未成年人个人信息的保护。根据相关法律法规的规定,若你是18周岁以下的未成年人,在使用STAR网络服务前,应事先取得你的家长或法定监护人的书面同意。若你是未成年人的监护人,当你对你所监护的未成年人的个人信息有相关疑问时,请通过第9节中的联系方式与我们联系。\n\n9. 与网站联系\n\n如果你有与隐私保护,政策实施或网站操作有关的问题,请将意见或建议发送至我们的邮箱:pr@shanghairanking.com\n最后,您是唯一对您的账号和密码信息负有保密责任的人。任何情况下,请小心妥善保管。\n"; diff --git a/src/content/userAgreement.js b/src/content/userAgreement.js new file mode 100644 index 0000000..0c2abd0 --- /dev/null +++ b/src/content/userAgreement.js @@ -0,0 +1,4 @@ +/** 自 star-guideline-ui 迁入,正文保持原站表述 */ +export const title = "《STAR网站用户协议》"; + +export const body = "欢迎使用STAR网站,请您使用STAR网站前,仔细阅读以下条款,如您开始使用或接受任何STAR服务的,即视为您已阅读并与STAR网站达成协议,自愿接受本协议全部条款、规则及内容的约束。您在开始使用STAR网站之前务必审慎阅读、充分理解各条款内容,对于本协议中已加粗字体显示的内容,您应重点阅读。如您对本协议有任何疑问的,应向STAR咨询,STAR可能会更新协议内容,您在使用STAR提供的各项服务之前,应仔细阅读本服务协议。如果您不同意本协议的约定,应立即停止使用STAR网站。\n\n一、 总则\n\n1. STAR网站拥有高度医学界影响力和业内认可度的指南排行榜。通过数据收集、分析、聚合、建模等为卫生部门、医疗机构、医学杂志、医务工作者和患者提供指南的分析参考。还为您逐步提供高质量指南收藏、分享和检索等增值服务。\n2. STAR网站所有权、经营权、管理权均属兰州大学健康数据科学研究院。\n3. STAR网站有权随时根据中华人民共和国有关法律、法规的变化、互联网的发展以及组织经营状况和经营策略的调整等情况调整产品规则及本协议的内容。您同意政策、通知等事项自发布之日起生效,您将自行及时查阅相关通知,并按照通知及变更后的产品政策及修订后的协议修订履行相关义务,如果您不同意变更事项应当于公示3日内以书面形式向STAR申请终止本协议,您继续使用变更后的服务视为接受变更内容。如发生协议条款与中华人民共和国法律相抵触时,则这些条款将完全按法律规定重新解释,其它条款则依旧保持对客户产生法律效力和影响。\n\n二、STAR网站用户\n\n1. 凡是使用过STAR网站产品的用户均为STAR网站用户(以下统称“您”)。针对您提供的注册资料,您同意并保证:\n1.1 提供合法、真实、准确、详尽的个人资料;\n1.2 如有变动,及时更新您资料。如果您提供的注册资料中存在不合法、不真实、不准确、不详尽的,您须承担因此引起的相应责任及后果,并且STAR网站保留随时终止您使用STAR网各项服务的权利;\n1.3 STAR网的用户名中不能含有任何侮辱、威胁、淫秽、谩骂等侵害他人合法权益的文字;\n1.4 若因输入的个人信息不真实而引发的一切问题、纠纷及所带来的后果,由您个人独立承担,STAR不负任何责任。\n2. STAR非常重视您的隐私保护和个人信息保护,我们将依照本协议及《STAR隐私政策》的规定收集、使用、储存和分享您的个人信息。\n\n三、 用户权利和义务\n\n1. 您享有言论自由的权利。\n2. 您的言行不得违反《计算机信息网络国际联网安全保护管理办法》、《互联网信息服务管理办法》、《互联网电子公告服务管理规定》、《维护互联网安全的决定》、《互联网新闻信息服务管理规定》、《最高人民法院、最高人民检察院关于办理利用信息网络实施诽谤等刑事案件适用法律若干问题的解释》等相关法律规定,不得在STAR统一用户登录平台发布、传播或以其它方式传送含有下列内容之一的信息:\n2.1 反对宪法所确定的基本原则的;\n2.2 危害国家安全,泄露国家秘密,颠覆国家政权,破坏国家统一的;\n2.3 损害国家荣誉和利益的;\n2.4 煽动民族仇恨、民族歧视、破坏民族团结的;\n2.5 破坏国家宗教政策,宣扬邪教和封建迷信的;\n2.6 散布谣言,扰乱社会秩序,破坏社会稳定的;\n2.7 散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;\n2.8 侮辱或者诽谤他人,侵害他人合法权利的;\n2.9 煽动非法集会、结社、游行、示威、聚众扰乱社会秩序的;\n2.10 以非法民间组织名义活动的;\n2.11 含有虚假、有害、胁迫、侵害他人隐私、骚扰、侵害、中伤、粗俗、猥亵、或其它道德上令人反感的内容;\n2.12 含有中国法律、法规、规章、条例以及任何具有法律效力之规范所限制或禁止的其它内容的。\n\n四、 权利声明和免责声明\n\n1. STAR网站是为您提供客观、准确、透明的实践指南评级查询和分析服务平台。STAR网站的所有数据均来源于公开发表的指南和受邀指南制订者提供的材料,以及通过专家评级获得的评级结果。STAR自身不控制、不修改数据的具体内容或表现形式,仅对从信息如实展示。STAR对于这些数据的真实性、完整性和时效性,不作任何形式的保证和承担任何责任。同时,STAR对于上述数据综合分析而得出的任何形式的相应结论,仅为您的决策提供参考,因使用分析而产生的任何后果,STAR不承担任何责任。STAR对由于您使用本网站服务、或依据本网站信息进行任何形式的活动引起的对任何第三方的损害不承担任何赔偿责任。\n2. STAR网的内容及提供的服务不含任何明示性或暗示性的声明、保证或条件,包括但不限于关于信息的真实性、完整性和时效性或适用于某一特定用途的明示或暗示性的声明、保证或条件。您使用提供的数据或服务时,即表示明确同意其使用本网站数据或服务所存在的风险及一切后果将完全由其自己承担。\n3. 由于政府禁令、现行生效的适用法律或法规的变更、火灾、地震、瘟疫、战争、停电、通讯线路中断、电信部门技术调整、因政府管制而造成网站的暂时性关闭等其他任何影响网络正常运营的不可预见、不可避免、不可克服和不可控制的事件(“不可抗力事件”);以及他人蓄意破坏,黑客攻击、计算机病毒,系统维护、系统升级,网络过载等技术问题(“技术事件”)而导致STAR网站能访问而造成的平台所提供的信息及数据的延误、停滞或错误,STAR网站无须承担责任。\n4. 您与其他用户之间因使用STAR网站而产生或可能产生的任何纠纷或损失,由您自行解决并承担相应的责任,与STAR网站无关。\n5. 任何由于因网络状况、通讯线路、第三方网站等不可抗力导致您不能正常使用STAR网站的,STAR不承担法律责任。\n6. STAR网站如因系统维护或升级等而需暂停服务时,将事先公告。若因硬件故障或其它不可抗力而导致暂停服务,于暂停服务期间造成的一切不便与损失,STAR网站不负任何责任。由于STAR网站的调整导致信息丢失或其他结果的,STAR不承担任何责任。\n7. 任何通过使用STAR网站而链接到的第三方网页均系他人制作或提供,您可能从该第三方网页上获得资讯及享用服务,STAR对其合法性概不负责,亦不承担任何法律责任。\n8. 除STAR网站注明之服务条款外,其他一切因使用STAR网站而可能遭致的意外、疏忽、侵权及其造成的损失(包括因下载被搜索链接到的第三方网站内容而感染电脑病毒),STAR对其概不负责,亦不承担任何法律责任。\n9. 任何单位或个人认为通过STAR链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权或其他权利,应该及时向STAR提出书面权利通知,并提供身份证明、权属证明及详细侵权情况证明。STAR在收到上述法律文件后,将会依法尽快断开相关链接内容。\n\n五、 权利声明\n\n任何个人或单位如果同时符合以下两个条件:\n1. 权利人发现网络用户利用网络服务侵害其合法权益或STAR统一用户登录平台所展示内容侵犯其合法权益;\n2. 权利人与该信息发布单位沟通并取得信息发布方书面更正文件,但STAR网站所展示内容通过手动刷新或自信息发布单位更正之日起三日后STAR网站中的内容仍未更新的。\n请上述个人或单位务必以书面的通讯方式向STAR提交权利通知。\n请注意:如果权利通知的陈述失实,权利通知提交者将承担对由此造成的全部法律责任(包括但不限于赔偿各种费用及律师费)。如果上述个人或单位不确定网络上可获取的资料是否侵犯了其合法权益,STAR建议该个人或单位首先咨询专业人士。\n为了STAR有效处理上述个人或单位的权利通知,请使用以下格式(包括各条款的序号):\n1. 权利人对涉嫌侵权内容拥有商标权、著作权和/或其他依法可以行使权利的权属证明。\n2. 请充分、明确地描述确信被侵犯了权利人合法权益的内容。\n3. 请指明涉嫌侵权网页的哪些内容侵犯了第2项中列明的权利人的合法权益。\n4. 请提供权利人具体的联络信息,包括姓名、身份证或护照复印件(对自然人)、单位登记证明复印件(对单位)、通信地址、电话号码、传真和电子邮件。\n5. 请在权利通知中加入如下关于通知内容真实性的声明:\n5.1 “我本人为所投诉内容的合法权利人;\n5.2 在我举报的网页上登载的内容侵犯了本人相应的合法权益;\n5.3 本人确认:如果本权利通知内容不完全属实,本人将承担由此产生的一切法律责任。\n6. 请在权利通知中加入以下陈述:“我保证,本通知中所述信息是充分、真实、准确的,我是所投诉内容的合法权利人,或,我已获授权,有权行使第2项中列明内容的权益。”\n7. 请您签署该文件,如果您是依法成立的机构或组织,请您加盖公章。\n请您把以上资料和联络方式书面通过邮件方式通知STAR:\n邮件地址:star_guidelines@vip.163.com\n\n六、 版本声明\n\n1、除非STAR另行声明,STAR网站内的所有产品、技术、软件、程序、数据及其他信息(包括但不限于文字、图像、图片、照片、音频、视频、图表、色彩、版面设计、电子文档)的所有权利所有知识产权(包括但不限于版权、商标权、专利权、商业秘密及其他所有相关权利)均归STAR所有。未经STAR许可,任何人不得擅自使用(包括但不限于通过任何机器人、蜘蛛等程序或设备监视、复制、传播、展示、镜像、上载、下载)STAR网站内的任何内容。\n2、STAR尊重知识产权,反对侵权盗版行为。未经STAR许可,任何人不得擅自(包括但不限于:以非法的方式复制、传播、展示、镜像、上载、下载)使用,或通过非常规方式(如:恶意干预STAR网站数据)影响STAR网站的正常服务,任何人不得擅自以STAR程序自动获得STAR网站数据。否则,STAR有权依法追究其法律责任。\n3、未经STAR许可,任何人禁止转载或商业使用STAR网站内文字、图形、表格等信息,否则,STAR保留采取一切合法方式维护正当利益的权利。经许可的信息的转载或商业使用时,应当以恰当方式说明,该信息来源于中华医学会杂志社指南与标准研究中心。\n\n七、 附则\n\n1.适用法律和管辖\n1.1 因您使用STAR网站而引起或与之相关的一切争议、权利主张或其它事项,均受中华人民共和国法律的管辖;\n1.2 您和兰州大学健康数据科学研究院发生争议的,应首先本着诚信原则通过协商加以解决。如果协商不成,则应向兰州市城关区人民法院或其上一级人民法院提起诉讼。\n"; diff --git a/src/data/dialCodes.js b/src/data/dialCodes.js new file mode 100644 index 0000000..215a6d7 --- /dev/null +++ b/src/data/dialCodes.js @@ -0,0 +1,1133 @@ +/** 国际区号列表(自 COUNTRIES 精简,中国置顶;台港澳按中国地区表述) */ +export const DIAL_CODES = [ + { + "code": "+86", + "en": "China", + "cn": "中国", + "ab": "CN" + }, + { + "code": "+93", + "en": "Afghanistan", + "cn": "阿富汗", + "ab": "AF" + }, + { + "code": "+355", + "en": "Albania", + "cn": "阿尔巴尼亚", + "ab": "AL" + }, + { + "code": "+213", + "en": "Algeria", + "cn": "阿尔及利亚", + "ab": "DZ" + }, + { + "code": "+376", + "en": "Andorra", + "cn": "安道尔共和国", + "ab": "AD" + }, + { + "code": "+244", + "en": "Angola", + "cn": "安哥拉", + "ab": "AO" + }, + { + "code": "+1264", + "en": "Anguilla", + "cn": "安圭拉岛", + "ab": "AI" + }, + { + "code": "+1268", + "en": "Antigua and Barbuda", + "cn": "安提瓜和巴布达", + "ab": "AG" + }, + { + "code": "+54", + "en": "Argentina", + "cn": "阿根廷", + "ab": "AR" + }, + { + "code": "+374", + "en": "Armenia", + "cn": "亚美尼亚", + "ab": "AM" + }, + { + "code": "+247", + "en": "Ascension", + "cn": "阿森松", + "ab": "ACS" + }, + { + "code": "+61", + "en": "Australia", + "cn": "澳大利亚", + "ab": "AU" + }, + { + "code": "+43", + "en": "Austria", + "cn": "奥地利", + "ab": "AT" + }, + { + "code": "+994", + "en": "Azerbaijan", + "cn": "阿塞拜疆", + "ab": "AZ" + }, + { + "code": "+1242", + "en": "Bahamas", + "cn": "巴哈马", + "ab": "BS" + }, + { + "code": "+973", + "en": "Bahrain", + "cn": "巴林", + "ab": "BH" + }, + { + "code": "+880", + "en": "Bangladesh", + "cn": "孟加拉国", + "ab": "BD" + }, + { + "code": "+1246", + "en": "Barbados", + "cn": "巴巴多斯", + "ab": "BB" + }, + { + "code": "+375", + "en": "Belarus", + "cn": "白俄罗斯", + "ab": "BY" + }, + { + "code": "+32", + "en": "Belgium", + "cn": "比利时", + "ab": "BE" + }, + { + "code": "+501", + "en": "Belize", + "cn": "伯利兹", + "ab": "BZ" + }, + { + "code": "+229", + "en": "Benin", + "cn": "贝宁", + "ab": "BJ" + }, + { + "code": "+1441", + "en": "Bermuda Is.", + "cn": "百慕大群岛", + "ab": "BM" + }, + { + "code": "+591", + "en": "Bolivia", + "cn": "玻利维亚", + "ab": "BO" + }, + { + "code": "+267", + "en": "Botswana", + "cn": "博茨瓦纳", + "ab": "BW" + }, + { + "code": "+55", + "en": "Brazil", + "cn": "巴西", + "ab": "BR" + }, + { + "code": "+673", + "en": "Brunei", + "cn": "文莱", + "ab": "BN" + }, + { + "code": "+359", + "en": "Bulgaria", + "cn": "保加利亚", + "ab": "BG" + }, + { + "code": "+226", + "en": "Burkina-faso", + "cn": "布基纳法索", + "ab": "BF" + }, + { + "code": "+95", + "en": "Burma", + "cn": "缅甸", + "ab": "MM" + }, + { + "code": "+257", + "en": "Burundi", + "cn": "布隆迪", + "ab": "BI" + }, + { + "code": "+237", + "en": "Cameroon", + "cn": "喀麦隆", + "ab": "CM" + }, + { + "code": "+1", + "en": "Canada", + "cn": "加拿大", + "ab": "CA" + }, + { + "code": "+1345", + "en": "Cayman Is.", + "cn": "开曼群岛", + "ab": "CIS" + }, + { + "code": "+236", + "en": "Central African Republic", + "cn": "中非共和国", + "ab": "CF" + }, + { + "code": "+235", + "en": "Chad", + "cn": "乍得", + "ab": "TD" + }, + { + "code": "+56", + "en": "Chile", + "cn": "智利", + "ab": "CL" + }, + { + "code": "+57", + "en": "Colombia", + "cn": "哥伦比亚", + "ab": "CO" + }, + { + "code": "+242", + "en": "Congo", + "cn": "刚果", + "ab": "CG" + }, + { + "code": "+682", + "en": "Cook Is.", + "cn": "库克群岛", + "ab": "CK" + }, + { + "code": "+506", + "en": "Costa Rica", + "cn": "哥斯达黎加", + "ab": "CR" + }, + { + "code": "+53", + "en": "Cuba", + "cn": "古巴", + "ab": "CU" + }, + { + "code": "+357", + "en": "Cyprus", + "cn": "塞浦路斯", + "ab": "CY" + }, + { + "code": "+420", + "en": "Czech Republic", + "cn": "捷克", + "ab": "CZ" + }, + { + "code": "+45", + "en": "Denmark", + "cn": "丹麦", + "ab": "DK" + }, + { + "code": "+253", + "en": "Djibouti", + "cn": "吉布提", + "ab": "DJ" + }, + { + "code": "+1890", + "en": "Dominica Rep.", + "cn": "多米尼加共和国", + "ab": "DO" + }, + { + "code": "+593", + "en": "Ecuador", + "cn": "厄瓜多尔", + "ab": "EC" + }, + { + "code": "+20", + "en": "Egypt", + "cn": "埃及", + "ab": "EG" + }, + { + "code": "+503", + "en": "EI Salvador", + "cn": "萨尔瓦多", + "ab": "SV" + }, + { + "code": "+372", + "en": "Estonia", + "cn": "爱沙尼亚", + "ab": "EE" + }, + { + "code": "+251", + "en": "Ethiopia", + "cn": "埃塞俄比亚", + "ab": "ET" + }, + { + "code": "+679", + "en": "Fiji", + "cn": "斐济", + "ab": "FJ" + }, + { + "code": "+358", + "en": "Finland", + "cn": "芬兰", + "ab": "FI" + }, + { + "code": "+33", + "en": "France", + "cn": "法国", + "ab": "FR" + }, + { + "code": "+594", + "en": "French Guiana", + "cn": "法属圭亚那", + "ab": "GF" + }, + { + "code": "+689", + "en": "French Polynesia", + "cn": "法属玻利尼西亚", + "ab": "PF" + }, + { + "code": "+241", + "en": "Gabon", + "cn": "加蓬", + "ab": "GA" + }, + { + "code": "+220", + "en": "Gambia", + "cn": "冈比亚", + "ab": "GM" + }, + { + "code": "+995", + "en": "Georgia", + "cn": "格鲁吉亚", + "ab": "GE" + }, + { + "code": "+49", + "en": "Germany", + "cn": "德国", + "ab": "DE" + }, + { + "code": "+233", + "en": "Ghana", + "cn": "加纳", + "ab": "GH" + }, + { + "code": "+350", + "en": "Gibraltar", + "cn": "直布罗陀", + "ab": "GI" + }, + { + "code": "+30", + "en": "Greece", + "cn": "希腊", + "ab": "GR" + }, + { + "code": "+1809", + "en": "Grenada", + "cn": "格林纳达", + "ab": "GD" + }, + { + "code": "+1671", + "en": "Guam", + "cn": "关岛", + "ab": "GU" + }, + { + "code": "+502", + "en": "Guatemala", + "cn": "危地马拉", + "ab": "GT" + }, + { + "code": "+224", + "en": "Guinea", + "cn": "几内亚", + "ab": "GN" + }, + { + "code": "+592", + "en": "Guyana", + "cn": "圭亚那", + "ab": "GY" + }, + { + "code": "+509", + "en": "Haiti", + "cn": "海地", + "ab": "HT" + }, + { + "code": "+504", + "en": "Honduras", + "cn": "洪都拉斯", + "ab": "HN" + }, + { + "code": "+852", + "en": "Hong Kong, China", + "cn": "中国香港", + "ab": "HK" + }, + { + "code": "+36", + "en": "Hungary", + "cn": "匈牙利", + "ab": "HU" + }, + { + "code": "+354", + "en": "Iceland", + "cn": "冰岛", + "ab": "IS" + }, + { + "code": "+91", + "en": "India", + "cn": "印度", + "ab": "IN" + }, + { + "code": "+62", + "en": "Indonesia", + "cn": "印度尼西亚", + "ab": "ID" + }, + { + "code": "+98", + "en": "Iran", + "cn": "伊朗", + "ab": "IR" + }, + { + "code": "+964", + "en": "Iraq", + "cn": "伊拉克", + "ab": "IQ" + }, + { + "code": "+353", + "en": "Ireland", + "cn": "爱尔兰", + "ab": "IE" + }, + { + "code": "+972", + "en": "Israel", + "cn": "以色列", + "ab": "IL" + }, + { + "code": "+39", + "en": "Italy", + "cn": "意大利", + "ab": "IT" + }, + { + "code": "+225", + "en": "Ivory Coast", + "cn": "科特迪瓦", + "ab": "IC" + }, + { + "code": "+1876", + "en": "Jamaica", + "cn": "牙买加", + "ab": "JM" + }, + { + "code": "+81", + "en": "Japan", + "cn": "日本", + "ab": "JP" + }, + { + "code": "+962", + "en": "Jordan", + "cn": "约旦", + "ab": "JO" + }, + { + "code": "+855", + "en": "Kampuchea (Cambodia )", + "cn": "柬埔寨", + "ab": "KH" + }, + { + "code": "+327", + "en": "Kazakstan", + "cn": "哈萨克斯坦", + "ab": "KZ" + }, + { + "code": "+254", + "en": "Kenya", + "cn": "肯尼亚", + "ab": "KE" + }, + { + "code": "+82", + "en": "Korea", + "cn": "韩国", + "ab": "KR" + }, + { + "code": "+965", + "en": "Kuwait", + "cn": "科威特", + "ab": "KW" + }, + { + "code": "+331", + "en": "Kyrgyzstan", + "cn": "吉尔吉斯坦", + "ab": "KG" + }, + { + "code": "+856", + "en": "Laos", + "cn": "老挝", + "ab": "LA" + }, + { + "code": "+371", + "en": "Latvia", + "cn": "拉脱维亚", + "ab": "LV" + }, + { + "code": "+961", + "en": "Lebanon", + "cn": "黎巴嫩", + "ab": "LB" + }, + { + "code": "+266", + "en": "Lesotho", + "cn": "莱索托", + "ab": "LS" + }, + { + "code": "+231", + "en": "Liberia", + "cn": "利比里亚", + "ab": "LR" + }, + { + "code": "+218", + "en": "Libya", + "cn": "利比亚", + "ab": "LY" + }, + { + "code": "+423", + "en": "Liechtenstein", + "cn": "列支敦士登", + "ab": "LI" + }, + { + "code": "+370", + "en": "Lithuania", + "cn": "立陶宛", + "ab": "LT" + }, + { + "code": "+352", + "en": "Luxembourg", + "cn": "卢森堡", + "ab": "LU" + }, + { + "code": "+853", + "en": "Macao, China", + "cn": "中国澳门", + "ab": "MO" + }, + { + "code": "+261", + "en": "Madagascar", + "cn": "马达加斯加", + "ab": "MG" + }, + { + "code": "+265", + "en": "Malawi", + "cn": "马拉维", + "ab": "MW" + }, + { + "code": "+60", + "en": "Malaysia", + "cn": "马来西亚", + "ab": "MY" + }, + { + "code": "+960", + "en": "Maldives", + "cn": "马尔代夫", + "ab": "MV" + }, + { + "code": "+223", + "en": "Mali", + "cn": "马里", + "ab": "ML" + }, + { + "code": "+356", + "en": "Malta", + "cn": "马耳他", + "ab": "MT" + }, + { + "code": "+1670", + "en": "Mariana Is", + "cn": "马里亚那群岛", + "ab": "MI" + }, + { + "code": "+596", + "en": "Martinique", + "cn": "马提尼克", + "ab": "MQ" + }, + { + "code": "+230", + "en": "Mauritius", + "cn": "毛里求斯", + "ab": "MU" + }, + { + "code": "+52", + "en": "Mexico", + "cn": "墨西哥", + "ab": "MX" + }, + { + "code": "+373", + "en": "Moldova, Republic of", + "cn": "摩尔多瓦", + "ab": "MD" + }, + { + "code": "+377", + "en": "Monaco", + "cn": "摩纳哥", + "ab": "MC" + }, + { + "code": "+976", + "en": "Mongolia", + "cn": "蒙古", + "ab": "MN" + }, + { + "code": "+1664", + "en": "Montserrat Is", + "cn": "蒙特塞拉特岛", + "ab": "MS" + }, + { + "code": "+212", + "en": "Morocco", + "cn": "摩洛哥", + "ab": "MA" + }, + { + "code": "+258", + "en": "Mozambique", + "cn": "莫桑比克", + "ab": "MZ" + }, + { + "code": "+264", + "en": "Namibia", + "cn": "纳米比亚", + "ab": "NA" + }, + { + "code": "+674", + "en": "Nauru", + "cn": "瑙鲁", + "ab": "NR" + }, + { + "code": "+977", + "en": "Nepal", + "cn": "尼泊尔", + "ab": "NP" + }, + { + "code": "+599", + "en": "Netheriands Antilles", + "cn": "荷属安的列斯", + "ab": "NAT" + }, + { + "code": "+31", + "en": "Netherlands", + "cn": "荷兰", + "ab": "NL" + }, + { + "code": "+64", + "en": "New Zealand", + "cn": "新西兰", + "ab": "NZ" + }, + { + "code": "+505", + "en": "Nicaragua", + "cn": "尼加拉瓜", + "ab": "NI" + }, + { + "code": "+227", + "en": "Niger", + "cn": "尼日尔", + "ab": "NE" + }, + { + "code": "+234", + "en": "Nigeria", + "cn": "尼日利亚", + "ab": "NG" + }, + { + "code": "+850", + "en": "North Korea", + "cn": "朝鲜", + "ab": "KP" + }, + { + "code": "+47", + "en": "Norway", + "cn": "挪威", + "ab": "NO" + }, + { + "code": "+968", + "en": "Oman", + "cn": "阿曼", + "ab": "OM" + }, + { + "code": "+92", + "en": "Pakistan", + "cn": "巴基斯坦", + "ab": "PK" + }, + { + "code": "+507", + "en": "Panama", + "cn": "巴拿马", + "ab": "PA" + }, + { + "code": "+675", + "en": "Papua New Cuinea", + "cn": "巴布亚新几内亚", + "ab": "PG" + }, + { + "code": "+595", + "en": "Paraguay", + "cn": "巴拉圭", + "ab": "PY" + }, + { + "code": "+51", + "en": "Peru", + "cn": "秘鲁", + "ab": "PE" + }, + { + "code": "+63", + "en": "Philippines", + "cn": "菲律宾", + "ab": "PH" + }, + { + "code": "+48", + "en": "Poland", + "cn": "波兰", + "ab": "PL" + }, + { + "code": "+351", + "en": "Portugal", + "cn": "葡萄牙", + "ab": "PT" + }, + { + "code": "+1787", + "en": "Puerto Rico", + "cn": "波多黎各", + "ab": "PR" + }, + { + "code": "+974", + "en": "Qatar", + "cn": "卡塔尔", + "ab": "QA" + }, + { + "code": "+262", + "en": "Reunion", + "cn": "留尼旺", + "ab": "RE" + }, + { + "code": "+40", + "en": "Romania", + "cn": "罗马尼亚", + "ab": "RO" + }, + { + "code": "+7", + "en": "Russia", + "cn": "俄罗斯", + "ab": "RU" + }, + { + "code": "+1758", + "en": "Saint Lueia", + "cn": "圣卢西亚", + "ab": "LC" + }, + { + "code": "+1784", + "en": "Saint Vincent", + "cn": "圣文森特岛", + "ab": "VC" + }, + { + "code": "+684", + "en": "Samoa Eastern", + "cn": "东萨摩亚(美)", + "ab": "SEA" + }, + { + "code": "+685", + "en": "Samoa Western", + "cn": "西萨摩亚", + "ab": "SWE" + }, + { + "code": "+378", + "en": "San Marino", + "cn": "圣马力诺", + "ab": "SM" + }, + { + "code": "+239", + "en": "Sao Tome and Principe", + "cn": "圣多美和普林西比", + "ab": "ST" + }, + { + "code": "+966", + "en": "Saudi Arabia", + "cn": "沙特阿拉伯", + "ab": "SA" + }, + { + "code": "+221", + "en": "Senegal", + "cn": "塞内加尔", + "ab": "SN" + }, + { + "code": "+248", + "en": "Seychelles", + "cn": "塞舌尔", + "ab": "SC" + }, + { + "code": "+232", + "en": "Sierra Leone", + "cn": "塞拉利昂", + "ab": "SL" + }, + { + "code": "+65", + "en": "Singapore", + "cn": "新加坡", + "ab": "SG" + }, + { + "code": "+421", + "en": "Slovakia", + "cn": "斯洛伐克", + "ab": "SK" + }, + { + "code": "+386", + "en": "Slovenia", + "cn": "斯洛文尼亚", + "ab": "SI" + }, + { + "code": "+677", + "en": "Solomon Is", + "cn": "所罗门群岛", + "ab": "SB" + }, + { + "code": "+252", + "en": "Somali", + "cn": "索马里", + "ab": "SO" + }, + { + "code": "+27", + "en": "South Africa", + "cn": "南非", + "ab": "ZA" + }, + { + "code": "+34", + "en": "Spain", + "cn": "西班牙", + "ab": "ES" + }, + { + "code": "+94", + "en": "Sri Lanka", + "cn": "斯里兰卡", + "ab": "LK" + }, + { + "code": "+249", + "en": "Sudan", + "cn": "苏丹", + "ab": "SD" + }, + { + "code": "+597", + "en": "Suriname", + "cn": "苏里南", + "ab": "SR" + }, + { + "code": "+268", + "en": "Swaziland", + "cn": "斯威士兰", + "ab": "SZ" + }, + { + "code": "+46", + "en": "Sweden", + "cn": "瑞典", + "ab": "SE" + }, + { + "code": "+41", + "en": "Switzerland", + "cn": "瑞士", + "ab": "CH" + }, + { + "code": "+963", + "en": "Syria", + "cn": "叙利亚", + "ab": "SY" + }, + { + "code": "+886", + "en": "Taiwan, China", + "cn": "中国台湾", + "ab": "TW" + }, + { + "code": "+992", + "en": "Tajikstan", + "cn": "塔吉克斯坦", + "ab": "TJ" + }, + { + "code": "+255", + "en": "Tanzania", + "cn": "坦桑尼亚", + "ab": "TZ" + }, + { + "code": "+66", + "en": "Thailand", + "cn": "泰国", + "ab": "TH" + }, + { + "code": "+228", + "en": "Togo", + "cn": "多哥", + "ab": "TG" + }, + { + "code": "+676", + "en": "Tonga", + "cn": "汤加", + "ab": "TO" + }, + { + "code": "+216", + "en": "Tunisia", + "cn": "突尼斯", + "ab": "TN" + }, + { + "code": "+90", + "en": "Turkey", + "cn": "土耳其", + "ab": "TR" + }, + { + "code": "+993", + "en": "Turkmenistan", + "cn": "土库曼斯坦", + "ab": "TM" + }, + { + "code": "+256", + "en": "Uganda", + "cn": "乌干达", + "ab": "UG" + }, + { + "code": "+380", + "en": "Ukraine", + "cn": "乌克兰", + "ab": "UA" + }, + { + "code": "+971", + "en": "United Arab Emirates", + "cn": "阿拉伯联合酋长国", + "ab": "AE" + }, + { + "code": "+44", + "en": "United Kiongdom", + "cn": "英国", + "ab": "GB" + }, + { + "code": "+598", + "en": "Uruguay", + "cn": "乌拉圭", + "ab": "UY" + }, + { + "code": "+998", + "en": "Uzbekistan", + "cn": "乌兹别克斯坦", + "ab": "UZ" + }, + { + "code": "+58", + "en": "Venezuela", + "cn": "委内瑞拉", + "ab": "VE" + }, + { + "code": "+84", + "en": "Vietnam", + "cn": "越南", + "ab": "VN" + }, + { + "code": "+967", + "en": "Yemen", + "cn": "也门", + "ab": "YE" + }, + { + "code": "+381", + "en": "Yugoslavia", + "cn": "南斯拉夫", + "ab": "YU" + }, + { + "code": "+243", + "en": "Zaire", + "cn": "扎伊尔", + "ab": "ZR" + }, + { + "code": "+260", + "en": "Zambia", + "cn": "赞比亚", + "ab": "ZM" + }, + { + "code": "+263", + "en": "Zimbabwe", + "cn": "津巴布韦", + "ab": "ZW" + } +]; + +export const DEFAULT_DIAL_CODE = "+86"; diff --git a/src/formily/MediaUpload.jsx b/src/formily/MediaUpload.jsx new file mode 100644 index 0000000..041e275 --- /dev/null +++ b/src/formily/MediaUpload.jsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { Button, Upload, message } from "antd"; +import { InboxOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons"; +import { connect, mapProps } from "@formily/react"; +import { contentApi } from "@/services"; + +/** + * 媒体上传(表单值为 URL 字符串,兼容 logo / image_url / file_path)。 + * 可直接用于 Ant Design Form.Item;Formily 请用下方 connect 导出的 MediaUpload。 + */ +export function MediaUploadBase({ + value, + onChange, + accept = "image/*", + listType = "picture-card", + category = "misc", + kind, + maxSizeMB, + disabled, + textContent, +}) { + const [uploading, setUploading] = useState(false); + const isImageCard = listType === "picture-card" || listType === "picture"; + const inferredKind = + kind || (accept && String(accept).startsWith("image") ? "image" : "file"); + const sizeLimit = + maxSizeMB ?? (inferredKind === "image" ? 8 : 50); + + const customRequest = async ({ file, onSuccess, onError }) => { + if (file.size > sizeLimit * 1024 * 1024) { + const err = new Error(`文件不能超过 ${sizeLimit}MB`); + message.error(err.message); + onError?.(err); + return; + } + const fd = new FormData(); + fd.append("file", file); + fd.append("category", category); + fd.append("kind", inferredKind); + setUploading(true); + try { + const res = await contentApi.uploadMedia(fd); + onChange?.(res.url); + message.success("上传成功"); + onSuccess?.(res); + } catch (err) { + message.error(err?.detail || "上传失败"); + onError?.(err); + } finally { + setUploading(false); + } + }; + + const clear = () => onChange?.(""); + + if (isImageCard) { + return ( +
+ + {value ? ( + 预览 + ) : ( +
+ +
+ {uploading ? "上传中…" : textContent || "上传图片"} +
+
+ )} +
+ {value ? ( + + ) : null} +
+ ); + } + + return ( +
+ +

+ +

+

+ {uploading ? "上传中…" : textContent || "点击或拖拽文件到此处上传"} +

+

不支持填写路径,请直接上传本地文件

+
+ {value ? ( +
+ + + {value.split("/").pop() || value} + + +
+ ) : null} +
+ ); +} + +export const MediaUpload = connect( + MediaUploadBase, + mapProps({ value: true, onChange: true }), +); + +export default MediaUpload; diff --git a/src/formily/SchemaField.jsx b/src/formily/SchemaField.jsx new file mode 100644 index 0000000..d1aa123 --- /dev/null +++ b/src/formily/SchemaField.jsx @@ -0,0 +1,37 @@ +import { createSchemaField } from "@formily/react"; +import { + Cascader, + Checkbox, + DatePicker, + FormItem, + FormLayout, + Input, + NumberPicker, + Password, + Radio, + Select, + Switch, + TimePicker, + TreeSelect, +} from "@formily/antd-v5"; +import { MediaUpload } from "./MediaUpload"; + +/** 统一注册后台表单可用组件,Schema 通过 x-component 引用 */ +export const SchemaField = createSchemaField({ + components: { + FormItem, + FormLayout, + Input, + Password, + NumberPicker, + Select, + TreeSelect, + Cascader, + Switch, + Checkbox, + Radio, + DatePicker, + TimePicker, + MediaUpload, + }, +}); diff --git a/src/formily/SchemaFormModal.jsx b/src/formily/SchemaFormModal.jsx new file mode 100644 index 0000000..149da19 --- /dev/null +++ b/src/formily/SchemaFormModal.jsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Modal } from "antd"; +import { createForm } from "@formily/core"; +import { FormProvider } from "@formily/react"; +import { FormLayout } from "@formily/antd-v5"; +import { SchemaField } from "./SchemaField"; +import { schemaModalBodyStyles } from "./modalStyles"; + +/** + * 基于 Formily JSON Schema 的 Modal 表单。 + * 内容区限制在一屏内滚动,避免 Modal 超出视口。 + */ +export default function SchemaFormModal({ + open, + title, + schema, + initialValues, + onCancel, + onSubmit, + okText = "保存", + cancelText = "取消", + width = 560, + confirmLoading = false, + layout = "vertical", + labelCol, + wrapperCol, +}) { + const initialRef = useRef(initialValues); + initialRef.current = initialValues; + + const form = useMemo( + () => + createForm({ + validateFirst: true, + initialValues: initialRef.current || {}, + }), + // 每次打开重建,避免上次校验/值残留 + // eslint-disable-next-line react-hooks/exhaustive-deps + [open], + ); + + useEffect(() => { + if (!open) return; + form.setValues(initialRef.current || {}); + }, [open, form]); + + const handleOk = async () => { + try { + await form.submit(async (values) => { + await onSubmit?.(values); + }); + } catch { + // 校验失败由 Formily 展示,此处吞掉避免未处理 Promise + } + }; + + return ( + + + + + + + + ); +} diff --git a/src/formily/index.js b/src/formily/index.js new file mode 100644 index 0000000..e2f375c --- /dev/null +++ b/src/formily/index.js @@ -0,0 +1,5 @@ +export { SchemaField } from "./SchemaField"; +export { MediaUpload, MediaUploadBase } from "./MediaUpload"; +export { default as SchemaFormModal } from "./SchemaFormModal"; +export { schemaModalBodyStyles, schemaModalProps } from "./modalStyles"; +export * from "./schemas"; diff --git a/src/formily/modalStyles.js b/src/formily/modalStyles.js new file mode 100644 index 0000000..fc6aacb --- /dev/null +++ b/src/formily/modalStyles.js @@ -0,0 +1,15 @@ +/** 后台 Modal 统一:内容区一屏内滚动,不超出视口 */ +export const schemaModalBodyStyles = { + body: { + maxHeight: "calc(100dvh - 180px)", + overflowY: "auto", + paddingTop: 12, + }, +}; + +export const schemaModalProps = { + centered: true, + destroyOnClose: true, + styles: schemaModalBodyStyles, + style: { maxHeight: "100dvh" }, +}; diff --git a/src/formily/schemas/index.js b/src/formily/schemas/index.js new file mode 100644 index 0000000..6390538 --- /dev/null +++ b/src/formily/schemas/index.js @@ -0,0 +1,223 @@ +/** + * 后台 Modal 表单 JSON Schema 汇总(Formily 规范)。 + * 图片/文件字段统一用 MediaUpload,禁止 Input 手填路径。 + */ + +export const linksSchema = { + type: "object", + properties: { + name: { + type: "string", + title: "名称", + required: true, + "x-decorator": "FormItem", + "x-component": "Input", + }, + link_url: { + type: "string", + title: "链接", + required: true, + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { placeholder: "https:// 或 /path" }, + }, + sort: { + type: "number", + title: "排序", + default: 0, + "x-decorator": "FormItem", + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" } }, + }, + is_disabled: { + type: "boolean", + title: "禁用", + default: false, + "x-decorator": "FormItem", + "x-component": "Switch", + }, + }, +}; + +export const reportsSchema = { + type: "object", + properties: { + name: { + type: "string", + title: "名称", + required: true, + "x-decorator": "FormItem", + "x-component": "Input", + }, + year: { + type: "number", + title: "年份", + required: true, + "x-decorator": "FormItem", + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" }, min: 1900, max: 2100 }, + }, + logo: { + type: "string", + title: "封面", + "x-decorator": "FormItem", + "x-decorator-props": { extra: "上传封面图,支持 jpg / png / webp" }, + "x-component": "MediaUpload", + "x-component-props": { + accept: "image/*", + listType: "picture-card", + category: "reports", + kind: "image", + }, + }, + file_path: { + type: "string", + title: "报告文件", + "x-decorator": "FormItem", + "x-decorator-props": { extra: "上传 PDF 等文件,勿填写路径" }, + "x-component": "MediaUpload", + "x-component-props": { + accept: ".pdf,.doc,.docx", + listType: "text", + category: "reports", + kind: "file", + maxSizeMB: 50, + }, + }, + is_disabled: { + type: "boolean", + title: "禁用", + default: false, + "x-decorator": "FormItem", + "x-component": "Switch", + }, + }, +}; + +export const bannersSchema = { + type: "object", + properties: { + title: { + type: "string", + title: "标题", + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { placeholder: "可选,便于后台识别" }, + }, + image_url: { + type: "string", + title: "轮播图片", + required: true, + "x-decorator": "FormItem", + "x-decorator-props": { + extra: "点击上传本地图片,支持 jpg / png / gif / webp,不超过 8MB", + }, + "x-component": "MediaUpload", + "x-component-props": { + accept: "image/*", + listType: "picture-card", + category: "banners", + kind: "image", + }, + }, + link_url: { + type: "string", + title: "点击跳转", + "x-decorator": "FormItem", + "x-decorator-props": { extra: "站内路径或完整 URL,可留空" }, + "x-component": "Input", + "x-component-props": { placeholder: "/about" }, + }, + sort: { + type: "number", + title: "排序", + default: 0, + "x-decorator": "FormItem", + "x-decorator-props": { extra: "数字越小越靠前" }, + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" } }, + }, + }, +}; + +export const specialtiesSchema = { + type: "object", + properties: { + name: { + type: "string", + title: "名称", + required: true, + "x-decorator": "FormItem", + "x-component": "Input", + }, + type: { + type: "number", + title: "类型", + "x-decorator": "FormItem", + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" } }, + }, + description: { + type: "string", + title: "描述", + "x-decorator": "FormItem", + "x-component": "Input.TextArea", + "x-component-props": { rows: 3 }, + }, + logo: { + type: "string", + title: "Logo", + "x-decorator": "FormItem", + "x-decorator-props": { extra: "上传 Logo,勿填写 URL" }, + "x-component": "MediaUpload", + "x-component-props": { + accept: "image/*", + listType: "picture-card", + category: "specialties", + kind: "image", + }, + }, + position: { + type: "number", + title: "排序", + "x-decorator": "FormItem", + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" } }, + }, + is_disabled: { + type: "boolean", + title: "禁用", + default: false, + "x-decorator": "FormItem", + "x-component": "Switch", + }, + }, +}; + +export const keywordsSchema = { + type: "object", + properties: { + name: { + type: "string", + title: "关键词", + required: true, + "x-decorator": "FormItem", + "x-component": "Input", + }, + search_hot: { + type: "number", + title: "热度", + default: 0, + "x-decorator": "FormItem", + "x-component": "NumberPicker", + "x-component-props": { style: { width: "100%" }, min: 0 }, + }, + is_show: { + type: "boolean", + title: "展示", + default: true, + "x-decorator": "FormItem", + "x-component": "Switch", + }, + }, +}; diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..b8d4446 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,23 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import zh from "./locales/zh.json"; +import en from "./locales/en.json"; + +const saved = localStorage.getItem("star_ui_lang") || "zh"; + +i18n.use(initReactI18next).init({ + resources: { + zh: { translation: zh }, + en: { translation: en }, + }, + lng: saved, + fallbackLng: "en", + interpolation: { escapeValue: false }, +}); + +export function setUiLanguage(lang) { + localStorage.setItem("star_ui_lang", lang); + return i18n.changeLanguage(lang); +} + +export default i18n; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..9cdc0fd --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,231 @@ +{ + "brand": "STAR International", + "brandTagline": "Scientificity, Transparency and Applicability Rankings for Practice Guidelines", + "nav": { + "home": "Home", + "ranking": "Ranking", + "chinaGuideRanking": "China Guideline Ranking", + "chinaConsensusRanking": "China Consensus Ranking", + "tcmGuideRanking": "TCM Guideline Ranking", + "tcmConsensusRanking": "TCM Consensus Ranking", + "orgRanking": "Organization Ranking", + "magazineRanking": "Journal Ranking", + "database": "Database", + "guidelines": "Guidelines / Consensus", + "guideRanking": "Guideline Ranking", + "organization": "Organizations", + "workgroup": "Rating Working Group", + "specialist": "Experts", + "news": "News", + "publication": "Resources", + "about": "About", + "statement": "Copyright", + "contact": "Contact", + "search": "Search", + "admin": "Admin", + "hotSearch": "Hot search" + }, + "actions": { + "search": "Search", + "viewMore": "More", + "back": "Back", + "login": "Sign in", + "logout": "Sign out", + "save": "Save", + "create": "Create", + "edit": "Edit", + "delete": "Delete", + "submit": "Submit", + "cancel": "Cancel", + "detail": "Detail", + "all": "All", + "close": "Close" + }, + "home": { + "heroTitle": "STAR International Guideline Evaluation", + "heroSubtitle": "Browse, search and rank clinical practice guidelines worldwide", + "featured": "Featured guidelines", + "reportDownload": "Report download", + "latestNews": "Latest news", + "banners": "Highlights", + "newsNotify": "News", + "resourceCenter": "Resources" + }, + "article": { + "title": "Document type", + "docTitle": "Title", + "guideline": "Guideline", + "consensus": "Consensus", + "year": "Year", + "score": "STAR score", + "starLevel": "STAR rating", + "scoreRank": "Score rank", + "organizes": "Organization type", + "magazines": "Journal type", + "subjects": "Subject type", + "introduction": "Introduction", + "content": "Content", + "pushDate": "Published", + "originalUrl": "Original link", + "tags": "Tags", + "likeNum": "Likes", + "collectNum": "Favorites", + "shareNum": "Shares", + "readNum": "Reads", + "shareTitle": "Share guideline", + "shareCopy": "Copy link", + "shareCopied": "Link copied", + "shareCopyPrompt": "Copy link to share", + "shareFailed": "Share failed", + "shareWechat": "WeChat", + "shareWeibo": "Weibo", + "shareWechatTip": "Copy the link and paste it in WeChat", + "shareWechatCopyTip": "Link is ready — open WeChat and paste to a chat or Moments", + "shareWechatInApp": "Tap ··· in the top-right corner to share with friends or Moments", + "shareWechatCopy": "Copy link", + "shareWechatStep1": "Copy the link below (already copied)", + "shareWechatStep2": "Open WeChat and pick a chat or Moments", + "shareWechatStep3": "Paste the link and send", + "shareDone": "Got it", + "comments": "Reader reviews", + "commentsEmpty": "No reviews yet", + "commentStar": "Rating", + "anonymous": "Anonymous", + "ranking": "Guideline ranking", + "empty": "No articles yet" + }, + "organize": { + "title": "Organizations", + "ranking": "Organization ranking", + "empty": "No organizations yet", + "searchPlaceholder": "Organization name" + }, + "magazine": { + "ranking": "Journal ranking", + "empty": "No journals yet" + }, + "professor": { + "title": "Experts", + "empty": "No experts yet" + }, + "news": { + "title": "News", + "empty": "No news yet" + }, + "publication": { + "title": "Resources", + "empty": "No publications yet" + }, + "about": { + "title": "About us", + "history": "History", + "workgroup": "Working group", + "method": "Rating method", + "supporters": "Supporters", + "empty": "No content yet", + "rdGroup": "Development group", + "rdGroupDesc": "49 multidisciplinary experts in clinical medicine, methodology, management and journal editing across five regions", + "verifyGroup": "Validation group", + "verifyGroupDesc": "34 raters validated STAR usability, internal reliability, inter-rater reliability, content validity and criterion validity", + "rateGroup": "Rating group", + "rateGroupDesc": "106 experts from 13 provinces and 42 specialties; steering committee, secretariat, clinical rating, methodology rating and quality control" + }, + "statement": { + "title": "Copyright", + "responsibility": "Disclaimer", + "email": "Contact email" + }, + "contact": { + "title": "Contact us", + "serve": "Service", + "media": "Media", + "star": "STAR team" + }, + "search": { + "title": "Search", + "placeholder": "Search articles, organizations, experts, news", + "placeholderShort": "Enter keywords", + "articles": "Articles", + "organizes": "Organizations", + "professors": "Experts", + "news": "News", + "empty": "No results found" + }, + "admin": { + "title": "Admin", + "dashboard": "Dashboard", + "articles": "Articles", + "organizes": "Organizations", + "columns": "Columns", + "news": "News", + "banners": "Banners", + "loginTitle": "Admin sign in", + "username": "Username", + "password": "Password" + }, + "member": { + "loginTitle": "Member sign in", + "registerTitle": "Member sign up", + "subtitle": "", + "name": "Name", + "namePlaceholder": "Enter your name", + "phone": "Phone", + "dialCode": "Country/region code", + "dialSearch": "Search country/region or code", + "dialEmpty": "No matching country/region code", + "organize": "Organization", + "organizePlaceholder": "Enter organization (optional)", + "password": "Password", + "phonePlaceholder": "Enter local number, no country/region code", + "phoneHint": "Saved as {{phone}}", + "phoneInvalid": "Please enter a valid phone number", + "passwordPlaceholder": "Enter password", + "passwordHint": "Set a password (at least 6 characters)", + "loginAction": "Sign in", + "register": "Sign up", + "noAccount": "No account?", + "hasAccount": "Already registered?", + "goRegister": "Create one", + "goLogin": "Sign in", + "account": "Member", + "myCollections": "My favorites", + "collectionsEmpty": "No favorites yet", + "browseArticles": "Browse the database", + "authFailed": "Sign-in failed. Check phone or password.", + "agreePrefix": "By signing up you agree to the", + "userAgreement": "User Agreement", + "privacyPolicy": "Privacy Policy", + "and": "and", + "agreeRequired": "Please agree to the User Agreement and Privacy Policy" + }, + "footer": { + "links": "Links", + "copyright": "All rights reserved", + "copyrightLine": "Copyright © STAR International Working Group", + "icp": "Public Security Record" + }, + "common": { + "loading": "Loading…", + "error": "Failed to load", + "langZh": "中文", + "langEn": "English", + "category": "Category", + "expand": "Expand", + "collapse": "Collapse", + "topN": "Top {{n}}", + "count": "Count", + "avgScore": "Avg STAR score", + "to": "to", + "total": "{{n}} results" + }, + "errorPage": { + "title": "Something went wrong", + "description": "Sorry, this page couldn’t be displayed. Please try again or return to the home page.", + "notFoundTitle": "Page not found", + "notFoundDesc": "The address may be outdated or mistyped. Please go back to the home page.", + "retry": "Reload", + "backHome": "Back to home", + "contact": "Contact us", + "devDetails": "Developer details (local only)" + } +} diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json new file mode 100644 index 0000000..6b10875 --- /dev/null +++ b/src/i18n/locales/zh.json @@ -0,0 +1,231 @@ +{ + "brand": "STAR International", + "brandTagline": "科学 · 透明 · 适用", + "nav": { + "home": "首页", + "ranking": "排名", + "chinaGuideRanking": "中国指南排名", + "chinaConsensusRanking": "中国共识排名", + "tcmGuideRanking": "中国传统医学指南排名", + "tcmConsensusRanking": "中国传统医学共识排名", + "orgRanking": "制订机构排名", + "magazineRanking": "发布杂志排名", + "database": "数据库", + "guidelines": "指南/共识", + "guideRanking": "指南排名", + "organization": "制订机构", + "workgroup": "评级工作组", + "specialist": "专家团队", + "news": "新闻通知", + "publication": "资源中心", + "about": "关于我们", + "statement": "版权声明", + "contact": "联系我们", + "search": "搜索", + "admin": "管理后台", + "hotSearch": "热门搜索" + }, + "actions": { + "search": "搜索", + "viewMore": "更多", + "back": "返回", + "login": "注册/登录", + "logout": "退出", + "save": "保存", + "create": "新建", + "edit": "编辑", + "delete": "删除", + "submit": "提交", + "cancel": "取消", + "detail": "详情", + "all": "全部", + "close": "关闭" + }, + "home": { + "heroTitle": "STAR 国际指南评价", + "heroSubtitle": "检索、浏览与排名全球临床实践指南与共识", + "featured": "指南推荐", + "reportDownload": "报告下载", + "latestNews": "最新新闻", + "banners": "专题推荐", + "newsNotify": "新闻通知", + "resourceCenter": "资源中心" + }, + "article": { + "title": "文献类型", + "docTitle": "标题", + "guideline": "指南", + "consensus": "共识", + "year": "所属年份", + "score": "STAR 得分", + "starLevel": "STAR 星级", + "scoreRank": "分数排名", + "organizes": "机构类型", + "magazines": "杂志类型", + "subjects": "学科类型", + "introduction": "简介", + "content": "正文", + "pushDate": "发布日期", + "originalUrl": "原文链接", + "tags": "标签", + "likeNum": "点赞", + "collectNum": "收藏", + "shareNum": "分享", + "readNum": "阅读", + "shareTitle": "分享指南", + "shareCopy": "复制链接", + "shareCopied": "链接已复制", + "shareCopyPrompt": "复制链接分享", + "shareFailed": "分享失败", + "shareWechat": "微信", + "shareWeibo": "微博", + "shareWechatTip": "复制链接后,到微信粘贴发送", + "shareWechatCopyTip": "链接已可粘贴;打开微信选择好友或朋友圈发送即可", + "shareWechatInApp": "请点击右上角 ···,选择分享给朋友或朋友圈", + "shareWechatCopy": "复制链接", + "shareWechatStep1": "点击下方按钮复制本文链接(已自动复制)", + "shareWechatStep2": "打开微信,选择好友或朋友圈", + "shareWechatStep3": "粘贴链接并发送", + "shareDone": "知道了", + "comments": "读者点评", + "commentsEmpty": "暂无点评", + "commentStar": "评价星级", + "anonymous": "匿名用户", + "ranking": "指南排名", + "empty": "暂无文献" + }, + "magazine": { + "ranking": "发布杂志排名", + "empty": "暂无杂志" + }, + "professor": { + "title": "专家", + "empty": "暂无专家" + }, + "news": { + "title": "新闻通知", + "empty": "暂无新闻" + }, + "publication": { + "title": "资源中心", + "empty": "暂无资源" + }, + "about": { + "title": "关于我们", + "history": "发展历程", + "workgroup": "工作组介绍", + "method": "评级方法", + "supporters": "支持单位", + "empty": "暂无内容", + "rdGroup": "研发工作组", + "rdGroupDesc": "全国五大区域,49位临床医学、方法学、管理学、期刊编辑学等多学科专家组成", + "verifyGroup": "验证工作组", + "verifyGroupDesc": "34名评级专家基于指南样本对STAR工具易用性、内在信度、评级员信度、内容效度及效标效度进行验证", + "rateGroup": "评级工作组", + "rateGroupDesc": "13个省级行政区、42个专业、106名专家;高级职称48名,中级职称6名,初级职称14名;项目下设指导委员会、秘书组、临床评级组、方法学评级组、质量控制组" + }, + "organize": { + "title": "制订机构", + "ranking": "制订机构排名", + "empty": "暂无机构", + "searchPlaceholder": "输入机构名称" + }, + "statement": { + "title": "版权声明", + "responsibility": "责任声明", + "email": "联系邮箱" + }, + "contact": { + "title": "联系我们", + "serve": "服务咨询", + "media": "媒体联络", + "star": "STAR 团队" + }, + "search": { + "title": "搜索", + "placeholder": "请输入关键词搜索文献、机构、专家、新闻", + "placeholderShort": "请输入关键词", + "articles": "文献", + "organizes": "机构", + "professors": "专家", + "news": "新闻", + "empty": "未找到相关结果" + }, + "admin": { + "title": "管理后台", + "dashboard": "概览", + "articles": "文献管理", + "organizes": "机构管理", + "columns": "栏目管理", + "news": "新闻管理", + "banners": "轮播管理", + "loginTitle": "后台登录", + "username": "用户名", + "password": "密码" + }, + "member": { + "loginTitle": "会员登录", + "registerTitle": "会员注册", + "subtitle": "", + "name": "姓名", + "namePlaceholder": "请输入姓名", + "phone": "手机号", + "dialCode": "国家/地区区号", + "dialSearch": "搜索国家/地区或区号", + "dialEmpty": "未找到匹配区号", + "organize": "单位", + "organizePlaceholder": "请输入单位(选填)", + "password": "密码", + "phonePlaceholder": "请输入本地手机号,不含区号", + "phoneHint": "将保存为 {{phone}}", + "phoneInvalid": "请输入有效手机号", + "passwordPlaceholder": "请输入密码", + "passwordHint": "请设置密码(至少 6 位)", + "loginAction": "登录", + "register": "注册并登录", + "noAccount": "还没有账号?", + "hasAccount": "已有账号?", + "goRegister": "立即注册", + "goLogin": "去登录", + "account": "会员", + "myCollections": "我的收藏", + "collectionsEmpty": "还没有收藏文献", + "browseArticles": "去数据库看看", + "authFailed": "登录失败,请检查手机号或密码", + "agreePrefix": "注册即同意", + "userAgreement": "《用户协议》", + "privacyPolicy": "《隐私协议》", + "and": "和", + "agreeRequired": "请先阅读并同意用户协议与隐私协议" + }, + "footer": { + "links": "相关链接", + "copyright": "版权所有", + "copyrightLine": "版权所有 © STAR International Working Group", + "icp": "京公网安备" + }, + "common": { + "loading": "加载中…", + "error": "加载失败", + "langZh": "中文", + "langEn": "English", + "category": "分类", + "expand": "展开", + "collapse": "收起", + "topN": "前{{n}}条", + "count": "数量", + "avgScore": "平均 STAR 星级", + "to": "至", + "total": "共 {{n}} 条" + }, + "errorPage": { + "title": "页面出了点问题", + "description": "抱歉,当前页面暂时无法正常显示。请稍后重试,或返回首页继续浏览。", + "notFoundTitle": "页面不存在", + "notFoundDesc": "您访问的地址可能已失效或输入有误,请返回首页重新查找。", + "retry": "重新加载", + "backHome": "返回首页", + "contact": "联系我们", + "devDetails": "开发信息(仅本地可见)" + } +} diff --git a/src/index.css b/src/index.css index b5c61c9..ba3f39e 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,145 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@theme { + --color-star-primary: #181b62; + --color-star-topbar: #1d2b40; + --color-star-bg: #f6f8fc; + --font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, + sans-serif; +} + +:root { + --star-primary: #181b62; + --star-topbar: rgb(29, 43, 64); + --star-bg: #f6f8fc; + --star-muted: #999; + --star-nav-hover: rgba(24, 27, 98, 0.06); + --star-nav-active-bar: #181b62; + --star-nav-dropdown-hover: #f5f7fa; + --star-nav-border: #eee; + --star-select-border: #d8dce8; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; + min-height: 100vh; + min-height: 100dvh; +} + +#root { + display: flex; + flex-direction: column; +} + +body { + margin: 0; + font-family: var(--font-sans); + color: #333; + background: #fff; +} + +a { + color: inherit; +} + +.site-container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding-left: 16px; + padding-right: 16px; +} + +@media (max-width: 640px) { + .site-container { + padding-left: 12px; + padding-right: 12px; + } +} + +.site-list-row { + list-style: disc inside; + line-height: 3; + padding-left: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.site-list-row:nth-child(odd) { + background-color: #f7f7f7; +} + +.site-list-row:hover { + background-color: #f5f7fa; +} + +.site-carousel img { + position: absolute; + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%); + object-fit: cover; +} + +/* 滚动条美化(WebKit + Firefox) */ +* { + scrollbar-width: thin; + scrollbar-color: var(--star-primary) #e8eaf2; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: #e8eaf2; +} + +*::-webkit-scrollbar-thumb { + background: var(--star-primary); + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb:hover { + background: #12154a; +} + +/* Tiptap 富文本 */ +.rich-text-editor .ProseMirror p.is-editor-empty:first-child::before { + color: #bfbfbf; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.rich-text-editor .ProseMirror { + font-size: 14px; + line-height: 1.6; +} + +.rich-text-editor .ProseMirror p { + margin: 0 0 0.5em; +} + +.rich-text-editor .ProseMirror ul, +.rich-text-editor .ProseMirror ol { + padding-left: 1.5em; + margin: 0 0 0.5em; +} + +.rich-text-editor .ProseMirror a { + color: #1677ff; + text-decoration: underline; +} diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx new file mode 100644 index 0000000..8d2a1e1 --- /dev/null +++ b/src/layouts/AdminLayout.jsx @@ -0,0 +1,216 @@ +import { useMemo, useState } from "react"; +import { Outlet, Link, useNavigate, useLocation, Navigate } from "react-router-dom"; +import { Layout, Menu, Button, ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import { + FileTextOutlined, + BankOutlined, + AppstoreOutlined, + NotificationOutlined, + DashboardOutlined, + LogoutOutlined, + TeamOutlined, + BookOutlined, + ReadOutlined, + SettingOutlined, + InfoCircleOutlined, + SolutionOutlined, + PartitionOutlined, +} from "@ant-design/icons"; +import { useDispatch, useSelector } from "react-redux"; + +const { Header, Sider, Content } = Layout; + +const MENU_ITEMS = [ + { key: "/admin", icon: , label: "概览" }, + { + key: "/admin/page-guide", + icon: , + label: "页面导览", + }, + { + key: "columns", + icon: , + label: "栏目管理", + children: [ + { key: "/admin/columns", label: "栏目列表" }, + { key: "/admin/templates", label: "模板预览" }, + ], + }, + { + key: "organizes", + icon: , + label: "机构管理", + children: [ + { key: "/admin/organize-categories", label: "机构类别" }, + { key: "/admin/organizes", label: "机构列表" }, + ], + }, + { + key: "subjects", + icon: , + label: "学科管理", + children: [{ key: "/admin/subjects", label: "学科列表" }], + }, + { + key: "magazines", + icon: , + label: "杂志管理", + children: [ + { key: "/admin/magazine-categories", label: "杂志类别" }, + { key: "/admin/magazines", label: "杂志列表" }, + ], + }, + { + key: "articles", + icon: , + label: "文献管理", + children: [ + { key: "/admin/star-rules", label: "评分规则" }, + { key: "/admin/article-types", label: "文献类型" }, + { key: "/admin/articles", label: "文献列表" }, + { key: "/admin/article-comments", label: "用户评价" }, + ], + }, + { + key: "reports", + icon: , + label: "报告管理", + children: [{ key: "/admin/reports", label: "报告列表" }], + }, + { + key: "specialists", + icon: , + label: "专家管理", + children: [ + { key: "/admin/work-groups", label: "工作组树" }, + { key: "/admin/work-group-terms", label: "工作组届次" }, + { key: "/admin/professors", label: "专家列表" }, + { key: "/admin/specialties", label: "专科列表" }, + ], + }, + { + key: "system", + icon: , + label: "系统管理", + children: [ + { key: "/admin/staff", label: "系统人员" }, + { key: "/admin/members", label: "前台用户" }, + { key: "/admin/keywords", label: "热词管理" }, + ], + }, + { + key: "introduce", + icon: , + label: "网站介绍", + children: [ + { key: "/admin/banners", label: "首页轮播" }, + { key: "/admin/about", label: "关于我们" }, + { key: "/admin/copyright", label: "版权声明" }, + { key: "/admin/contact", label: "联系我们" }, + { key: "/admin/links", label: "外部链接" }, + ], + }, + { + key: "news", + icon: , + label: "新闻管理", + children: [ + { key: "/admin/news", label: "新闻列表" }, + { key: "/admin/publications", label: "资源中心" }, + ], + }, +]; + +function findOpenKey(pathname) { + for (const item of MENU_ITEMS) { + if (item.children?.some((c) => c.key === pathname)) return item.key; + } + return undefined; +} + +export default function AdminLayout() { + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + const token = useSelector((s) => s.user.token); + const initialOpen = useMemo( + () => findOpenKey(location.pathname), + // 仅初始化一次,避免切换时强行收起 + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const [openKeys, setOpenKeys] = useState(initialOpen ? [initialOpen] : []); + + if (!token) { + return ; + } + + return ( + + + +
+ 内容管理后台 +
+ { + if (key.startsWith("/")) navigate(key); + }} + /> + + +
+ + ← 返回前台 + + +
+ + + +
+ + + ); +} diff --git a/src/layouts/PublicLayout.jsx b/src/layouts/PublicLayout.jsx new file mode 100644 index 0000000..d61e5c7 --- /dev/null +++ b/src/layouts/PublicLayout.jsx @@ -0,0 +1,496 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Outlet, + Link, + NavLink, + useNavigate, + useLocation, +} from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { setUiLanguage } from "@/i18n"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { HEADER_OFFSET } from "@/config/nav"; +import { contentApi } from "@/services"; +import BrandLogo from "@/components/site/BrandLogo"; +import { + buildColumnNavTree, + fallbackFooterNav, + fallbackTopNav, +} from "@/utils/navTree"; + +/** + * 详情页归属到列表栏目,避免进入详情后顶栏 active 消失。 + * key: 详情 pathname,value: 应对齐高亮的列表 path。 + */ +const DETAIL_PARENT_PATHS = { + "/fingerpostDetail": "/guidelineAndConsensus", + "/organization/detail": "/organization", + "/specialist/specialistDetail": "/specialist", + "/newsDetail": "/newsList", + "/publicationDetail": "/publication", +}; + +function resolveActivePathname(pathname) { + return DETAIL_PARENT_PATHS[pathname] || pathname; +} + +function navActive(pathname, search, to) { + const activePath = resolveActivePathname(pathname); + const [path, query = ""] = to.split("?"); + if (path === "/index") { + return activePath === "/" || activePath === "/index"; + } + if (!activePath.startsWith(path)) return false; + if (!query) return true; + // 详情页映射到列表后,不再用 query 细分子项(避免排名子菜单误高亮) + if (activePath !== pathname) return true; + const want = new URLSearchParams(query); + const cur = new URLSearchParams(search); + for (const [k, v] of want.entries()) { + if (cur.get(k) !== v) return false; + } + return true; +} + +export default function PublicLayout() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + const memberToken = useSelector((s) => s.member?.token); + const memberProfile = useSelector((s) => s.member?.profile); + const [keyword, setKeyword] = useState(""); + const [openMenu, setOpenMenu] = useState(null); + const [mobileOpen, setMobileOpen] = useState(false); + const [columns, setColumns] = useState(null); + const navRef = useRef(null); + const itemRefs = useRef(new Map()); + const [indicator, setIndicator] = useState({ + left: 0, + width: 0, + visible: false, + }); + const [indicatorReady, setIndicatorReady] = useState(false); + + useEffect(() => { + let alive = true; + contentApi + .listColumns({ limit: 200 }) + .then((res) => { + if (!alive) return; + setColumns(res.results || []); + }) + .catch(() => { + if (alive) setColumns([]); + }); + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + if (memberToken && !memberProfile) { + dispatch.member.fetchMe(); + } + }, [memberToken, memberProfile, dispatch]); + + const topNav = useMemo(() => { + if (!columns) return fallbackTopNav(t); + const tree = buildColumnNavTree(columns, 1); + return tree.length ? tree : fallbackTopNav(t); + }, [columns, t]); + + const footerNav = useMemo(() => { + if (!columns) return fallbackFooterNav(t); + const tree = buildColumnNavTree(columns, 3); + return tree.length ? tree : fallbackFooterNav(t); + }, [columns, t]); + + const hotWords = useMemo( + () => + i18n.language === "zh" + ? ["影像", "指南", "共识"] + : ["imaging", "guideline", "consensus"], + [i18n.language], + ); + + const onSearch = () => { + const q = keyword.trim(); + if (q) navigate(`/search?q=${encodeURIComponent(q)}`); + }; + + const isItemActive = (item) => { + if ( + item.children?.some((c) => + navActive(location.pathname, location.search, c.to), + ) + ) { + return true; + } + return navActive(location.pathname, location.search, item.to); + }; + + const activeNavId = useMemo(() => { + const active = topNav.find((item) => isItemActive(item)); + return active?.id ?? null; + }, [topNav, location.pathname, location.search]); + + const setItemRef = (id) => (el) => { + if (el) itemRefs.current.set(id, el); + else itemRefs.current.delete(id); + }; + + const updateIndicator = useCallback(() => { + const navEl = navRef.current; + if (!navEl || activeNavId == null) { + setIndicator((prev) => ({ ...prev, width: 0, visible: false })); + return; + } + const el = itemRefs.current.get(activeNavId); + if (!el) { + setIndicator((prev) => ({ ...prev, width: 0, visible: false })); + return; + } + const navRect = navEl.getBoundingClientRect(); + const rect = el.getBoundingClientRect(); + const inset = 10; + setIndicator({ + left: rect.left - navRect.left + inset, + width: Math.max(0, rect.width - inset * 2), + visible: true, + }); + setIndicatorReady(true); + }, [activeNavId]); + + useLayoutEffect(() => { + updateIndicator(); + }, [updateIndicator, topNav, i18n.language]); + + useEffect(() => { + const onResize = () => updateIndicator(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [updateIndicator]); + + // 桌面主导航(指示条由共享元素滑动,不在单项上画) + const linkClass = (active) => + cn( + "inline-flex h-10 items-center px-5 text-base no-underline transition-colors", + "text-(--star-primary) hover:bg-(--star-nav-hover)", + active ? "font-bold" : "font-semibold", + ); + + // 移动端一级项:左侧指示条 + const mobileLinkClass = (active) => + cn( + "relative flex h-10 items-center px-5 text-base no-underline transition-colors", + "text-(--star-primary)", + active + ? "bg-(--star-nav-hover) font-bold before:absolute before:top-2 before:bottom-2 before:left-0 before:w-0.5 before:rounded-full before:bg-(--star-nav-active-bar)" + : "font-semibold hover:bg-(--star-nav-hover)", + ); + + const dropdownItemClass = (active) => + cn( + "relative block px-5 py-2.5 text-base no-underline transition-colors", + active + ? "bg-(--star-nav-hover) font-bold text-(--star-primary) before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-(--star-nav-active-bar)" + : "font-medium text-[#333] hover:bg-(--star-nav-dropdown-hover) hover:text-(--star-primary)", + ); + + return ( +
+
+
+
+
+ {t("nav.hotSearch")} + {hotWords.map((w, i) => ( + + {i > 0 && ( + + · + + )} + + + ))} +
+ {memberToken ? ( + + + {t("member.myCollections")} + + + {memberProfile?.name || t("member.account")} + + + + ) : ( + + {t("actions.login")} + + )} + +
+
+ +
+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSearch()} + placeholder={t("search.placeholderShort")} + className="h-8 w-55 rounded-full border-[#ccc] px-3 text-sm focus:border-(--star-primary)" + /> + +
+ +
+ +
+ +
+ + {mobileOpen && ( +
+ {topNav.map((item) => ( +
+ setMobileOpen(false)} + > + {item.name} + + {item.children?.map((child) => ( + setMobileOpen(false)} + > + {child.name} + + ))} +
+ ))} +
+ )} +
+ +
+ {/* 子页面根节点随主栏撑满,避免短内容页中间高度不足、页脚上顶 */} +
+ +
+
+ +
+
+
+ {footerNav.map((group) => ( +
+
{group.name}
+ {( + group.children || [ + { id: group.id, name: group.name, to: group.to }, + ] + ).map((link) => ( + + {link.name} + + ))} +
+ ))} +
+
+ +
+ WeChat + Weibo +
+

+ {t("footer.copyrightLine")} +

+

+ {t("footer.icp")} +

+ + {t("nav.admin")} + +
+
+
+
+ ); +} diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 0000000..378ccef --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/src/main.jsx b/src/main.jsx index 646c248..c8c8747 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,47 +1,48 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; +import "@/i18n"; import { Provider } from "react-redux"; -import { - createBrowserRouter, - RouterProvider, - Navigate, -} from "react-router-dom"; +import { RouterProvider } from "react-router-dom"; import { ConfigProvider } from "antd"; import { getPersistor } from "@rematch/persist"; import { PersistGate } from "redux-persist/lib/integration/react"; import { store } from "@/store"; - -import locale from "antd/locale/zh_CN"; +import { useTranslation } from "react-i18next"; +import zhCN from "antd/locale/zh_CN"; +import enUS from "antd/locale/en_US"; import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; +import "dayjs/locale/en"; import AuthProvider from "./components/AuthProvider"; -dayjs.locale("zh-cn"); - -import App from "./App.jsx"; +import router from "@/router"; const persistor = getPersistor(); -const router = createBrowserRouter([ - { - path: "/", - element: , - }, -]); +function AppProviders({ children }) { + const { i18n } = useTranslation(); + const antdLocale = i18n.language === "zh" ? zhCN : enUS; + dayjs.locale(i18n.language === "zh" ? "zh-cn" : "en"); + + return ( + + {children} + + ); +} createRoot(document.getElementById("root")).render( - + - + diff --git a/src/models/index.js b/src/models/index.js index c69d21e..43e1ff5 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -1 +1,2 @@ export { user } from "./user"; +export { member } from "./member"; diff --git a/src/models/member.js b/src/models/member.js new file mode 100644 index 0000000..9c6ca6c --- /dev/null +++ b/src/models/member.js @@ -0,0 +1,59 @@ +import { userApi } from "@/services"; +import { feedback } from "@/utils"; + +export const member = { + state: { + token: undefined, + profile: null, + }, + reducers: { + update(state, payload) { + return { + ...state, + ...payload, + }; + }, + logout() { + return { + token: undefined, + profile: null, + }; + }, + }, + effects: (dispatch) => ({ + async login(params) { + try { + const res = await userApi.memberLogin(params); + this.update({ + token: res.token, + profile: res.member || null, + }); + } catch (err) { + feedback(err); + return "error"; + } + }, + async register(params) { + try { + const res = await userApi.memberRegister(params); + this.update({ + token: res.token, + profile: res.member || null, + }); + } catch (err) { + feedback(err); + return "error"; + } + }, + async fetchMe() { + try { + const profile = await userApi.memberProfile(); + this.update({ profile }); + } catch (err) { + // token 失效时清空前台登录态 + dispatch.member.logout(); + return "error"; + } + }, + }), +}; diff --git a/src/pages/RouteErrorPage.jsx b/src/pages/RouteErrorPage.jsx new file mode 100644 index 0000000..516d04b --- /dev/null +++ b/src/pages/RouteErrorPage.jsx @@ -0,0 +1,172 @@ +import { + isRouteErrorResponse, + Link, + useNavigate, + useRouteError, +} from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import BrandLogo from "@/components/site/BrandLogo"; +import { Button } from "@/components/ui/button"; + +function resolveErrorInfo(error, t) { + if (isRouteErrorResponse(error)) { + const status = error.status; + if (status === 404) { + return { + status, + title: t("errorPage.notFoundTitle"), + description: t("errorPage.notFoundDesc"), + }; + } + return { + status, + title: t("errorPage.title"), + description: + (typeof error.data === "string" && error.data) || + error.statusText || + t("errorPage.description"), + }; + } + + if (error instanceof Error) { + return { + status: null, + title: t("errorPage.title"), + description: t("errorPage.description"), + detail: error.message, + stack: error.stack, + }; + } + + return { + status: null, + title: t("errorPage.title"), + description: t("errorPage.description"), + detail: error ? String(error) : null, + }; +} + +export default function RouteErrorPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const error = useRouteError(); + const info = resolveErrorInfo(error, t); + const isDev = import.meta.env.DEV; + + return ( +
+ {/* 背景氛围:柔和径向渐变 + 细网格 */} +
+
+ +
+ +
+ +
+
+ + + + + +
+ + {info.status ? ( +

+ {info.status} +

+ ) : null} + +

+ {info.title} +

+

+ {info.description} +

+ +
+ + +
+ + + {t("errorPage.contact")} + + + {isDev && (info.detail || info.stack) ? ( +
+ + {t("errorPage.devDetails")} + +
+              {info.detail}
+              {info.stack && info.detail !== info.stack
+                ? `\n\n${info.stack}`
+                : null}
+            
+
+ ) : null} +
+ +
+ {t("footer.copyrightLine")} +
+
+ ); +} diff --git a/src/pages/admin/AboutAdminPage.jsx b/src/pages/admin/AboutAdminPage.jsx new file mode 100644 index 0000000..ae8e379 --- /dev/null +++ b/src/pages/admin/AboutAdminPage.jsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { Button, Form, Input, Spin, message } from "antd"; +import { contentApi } from "@/services"; +import { MediaUploadBase } from "@/formily"; + +export default function AboutAdminPage() { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [record, setRecord] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const data = await contentApi.getAbout(); + if (data?.id) { + setRecord(data); + form.setFieldsValue({ + ...data, + work_group: JSON.stringify(data.work_group || [], null, 2), + product: JSON.stringify(data.product || [], null, 2), + }); + } else { + setRecord(null); + form.resetFields(); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onSubmit = async () => { + const values = await form.validateFields(); + let work_group = []; + let product = []; + try { + work_group = values.work_group ? JSON.parse(values.work_group) : []; + product = values.product ? JSON.parse(values.product) : []; + } catch { + message.error("工作组/产品 JSON 格式不正确"); + return; + } + const payload = { ...values, work_group, product }; + setSaving(true); + try { + if (record?.id) await contentApi.updateAbout(record.id, payload); + else await contentApi.createAbout(payload); + message.success("保存成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/src/pages/admin/ArticleCommentsAdminPage.jsx b/src/pages/admin/ArticleCommentsAdminPage.jsx new file mode 100644 index 0000000..6626a7f --- /dev/null +++ b/src/pages/admin/ArticleCommentsAdminPage.jsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from "react"; +import { + Button, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Table, + message, +} from "antd"; +import dayjs from "dayjs"; +import { contentApi, userApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +const STATUS_MAP = { + 1: "待审核", + 2: "不通过", + 3: "通过", +}; + +export default function ArticleCommentsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [articles, setArticles] = useState([]); + const [members, setMembers] = useState([]); + const [status, setStatus] = useState(""); + const [range, setRange] = useState(null); + const [open, setOpen] = useState(false); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const params = { limit: 200 }; + if (status) params.review_status = status; + if (range?.[0]) params.start_date = range[0].format("YYYY-MM-DD"); + if (range?.[1]) params.end_date = range[1].format("YYYY-MM-DD"); + const res = await contentApi.listArticleComments(params); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + const loadOptions = async () => { + const [a, m] = await Promise.all([ + contentApi.listArticles({ limit: 200 }), + userApi.listMembers({ limit: 200 }), + ]); + setArticles(a.results || []); + setMembers(m.results || []); + }; + + useEffect(() => { + load(); + loadOptions(); + }, []); + + const onReview = async (row, review_status) => { + try { + await contentApi.updateArticleComment(row.id, { review_status }); + message.success("已更新审核状态"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onCreate = async () => { + const values = await form.validateFields(); + try { + await contentApi.createArticleComment({ + ...values, + review_status: values.review_status || 1, + }); + message.success("已创建"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+
+ ({ + value: a.id, + label: a.title, + }))} + /> + + + + + + +
+ ); +} diff --git a/src/pages/admin/ArticleTypesAdminPage.jsx b/src/pages/admin/ArticleTypesAdminPage.jsx new file mode 100644 index 0000000..98d93df --- /dev/null +++ b/src/pages/admin/ArticleTypesAdminPage.jsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Switch, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function ArticleTypesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listArticleTypes({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ sort: 0, is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue(row); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateArticleType(editing.id, values); + else await contentApi.createArticleType(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteArticleType(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ + v || "-", + }, + { title: "排序", dataIndex: "sort", width: 80 }, + { + title: "禁用", + dataIndex: "is_disabled", + width: 80, + render: (v) => (v ? "是" : "否"), + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + (v ? String(v).toUpperCase() : v)} + > + + + + + + + + + + + + +
+ + ); +} diff --git a/src/pages/admin/ArticlesAdminPage.jsx b/src/pages/admin/ArticlesAdminPage.jsx new file mode 100644 index 0000000..2c437a3 --- /dev/null +++ b/src/pages/admin/ArticlesAdminPage.jsx @@ -0,0 +1,304 @@ +import { useEffect, useState } from "react"; +import { + Button, + Col, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +const idsOf = (list) => (Array.isArray(list) ? list.map((x) => x.id ?? x) : []); + +export default function ArticlesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [articleTypes, setArticleTypes] = useState([]); + const [subjects, setSubjects] = useState([]); + const [organizes, setOrganizes] = useState([]); + const [magazines, setMagazines] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const loadOptions = async () => { + const [typeRes, subRes, orgRes, magRes] = await Promise.all([ + contentApi.listArticleTypes({ limit: 200 }), + contentApi.listSubjects({ limit: 200 }), + contentApi.listOrganizes({ limit: 200 }), + contentApi.listMagazines({ limit: 200 }), + ]); + setArticleTypes(typeRes.results || []); + setSubjects(subRes.results || []); + setOrganizes(orgRes.results || []); + setMagazines(magRes.results || []); + }; + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listArticles({ limit: 100 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + loadOptions(); + }, []); + + const typeNameMap = Object.fromEntries( + articleTypes.map((t) => [t.id, t.name]), + ); + const enabledTypes = articleTypes.filter((t) => !t.is_disabled); + const defaultTypeId = enabledTypes[0]?.id; + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + type: defaultTypeId, + year: new Date().getFullYear(), + subjects: [], + organizes: [], + magazines: [], + }); + setOpen(true); + }; + + const openEdit = async (row) => { + const detail = await contentApi.getArticle(row.id); + setEditing(detail); + form.setFieldsValue({ + title: detail.title, + type: detail.type, + year: detail.year, + score: detail.score ? Number(detail.score) : undefined, + introduction: detail.introduction, + content: detail.content, + push_date: detail.push_date, + rank: detail.rank, + serial_number: detail.serial_number, + subjects: idsOf(detail.subjects), + organizes: idsOf(detail.organizes), + magazines: idsOf(detail.magazines), + }); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + // 编号由后端自动生成,提交时不传 + const { serial_number: _sn, ...payload } = values; + try { + if (editing) { + await contentApi.updateArticle(editing.id, payload); + } else { + await contentApi.createArticle(payload); + } + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteArticle(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ + + +
row.type_name || typeNameMap[v] || "-", + }, + { title: "年份", dataIndex: "year", width: 90 }, + { title: "评分", dataIndex: "score", width: 90 }, + { + title: "学科", + width: 140, + ellipsis: true, + render: (_, row) => + (row.subjects || []).map((s) => s.name).join("、") || "-", + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)} + > + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + width={760} + destroyOnClose + > +
+ + + + + +
+ + + + + + + + + + + + + + + + + + ({ value: o.id, label: o.name }))} + placeholder="选择机构" + maxTagCount="responsive" + /> + + +
, + }, + { title: "标题", dataIndex: "title" }, + { + title: "跳转链接", + dataIndex: "link_url", + ellipsis: true, + render: (v) => v || "-", + }, + { title: "排序", dataIndex: "sort", width: 80 }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onSubmit={onSubmit} + confirmLoading={saving} + /> + + ); +} diff --git a/src/pages/admin/ColumnsAdminPage.jsx b/src/pages/admin/ColumnsAdminPage.jsx new file mode 100644 index 0000000..7a806d8 --- /dev/null +++ b/src/pages/admin/ColumnsAdminPage.jsx @@ -0,0 +1,482 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Cascader, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Radio, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, + message, +} from "antd"; +import { EyeOutlined, PlusOutlined } from "@ant-design/icons"; +import { Link } from "react-router-dom"; +import { contentApi } from "@/services"; +import RichTextEditor from "@/components/admin/RichTextEditor"; +import { schemaModalBodyStyles } from "@/formily"; +import { + COLUMN_TEMPLATE_OPTIONS, + getTemplateById, + getTemplateLabel, + templateCascaderValue, +} from "@/config/columnTemplates"; + +const { Text, Paragraph } = Typography; + +const POSITION_OPTIONS = [ + { value: 1, label: "顶部" }, + { value: 2, label: "中部" }, + { value: 3, label: "底部" }, +]; + +const SECTIONS = [ + { + key: "1", + position: 1, + label: "顶部导航", + hint: "对应前台页眉菜单栏。一级 = 菜单项;有子项时悬停展开下拉。", + }, + { + key: "2", + position: 2, + label: "中部栏目", + hint: "对应前台中部展示区块(如首页分区)。可按模板绑定列表/排名页。", + }, + { + key: "3", + position: 3, + label: "底部页脚", + hint: "对应前台页脚分组。一级 = 分组标题;子项 = 分组下的链接。", + }, +]; + +function buildTree(list) { + const sorted = [...list].sort( + (a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id + ); + const byParent = new Map(); + sorted.forEach((row) => { + const pid = row.parent == null ? 0 : row.parent; + if (!byParent.has(pid)) byParent.set(pid, []); + byParent.get(pid).push(row); + }); + const walk = (pid) => + (byParent.get(pid) || []).map((row) => { + const children = walk(row.id); + return children.length ? { ...row, children } : { ...row }; + }); + return walk(0); +} + +export default function ColumnsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [tab, setTab] = useState("1"); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + /** 新建时指定的上级栏目 id;null 表示一级 */ + const [createParentId, setCreateParentId] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listColumns({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const section = SECTIONS.find((s) => s.key === tab) || SECTIONS[0]; + + const treeData = useMemo(() => { + const filtered = rows.filter((r) => r.position === section.position); + return buildTree(filtered); + }, [rows, section.position]); + + const nameById = useMemo(() => { + const m = new Map(); + rows.forEach((r) => m.set(r.id, r.name)); + return m; + }, [rows]); + + const openCreate = (parentRow = null) => { + setEditing(null); + setCreateParentId(parentRow?.id ?? null); + form.resetFields(); + form.setFieldsValue({ + name: "", + description: "", + note: "", + position: parentRow?.position ?? section.position, + sort: 1, + template_id: undefined, + _templatePath: [], + is_disabled: false, + }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + setCreateParentId(null); + form.setFieldsValue({ + name: row.name, + description: row.description || "", + note: row.note || "", + position: row.position, + sort: row.sort ?? 1, + template_id: row.template_id || undefined, + _templatePath: templateCascaderValue(row.template_id), + is_disabled: row.is_disabled, + }); + setOpen(true); + }; + + const onTemplateChange = (value) => { + // 「无」为一层;正式模板为 [groupId, templateId] + const templateId = + Array.isArray(value) && value.length >= 2 ? value[1] : null; + form.setFieldsValue({ + template_id: templateId || null, + _templatePath: templateId ? value : [], + }); + }; + + /** 跳转路径由模板决定,后台不可手填;无固定 url 的模板则保留原 path(兼容历史数据) */ + const resolvePath = (templateId) => { + const tpl = getTemplateById(templateId); + if (tpl?.url) return tpl.url; + return editing?.path || ""; + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const templateId = values.template_id || null; + const payload = { + name: values.name, + description: values.description || "", + note: values.note || "", + position: values.position, + sort: values.sort, + path: resolvePath(templateId), + template_id: templateId, + is_disabled: values.is_disabled, + }; + if (!editing) { + payload.parent = createParentId; + } + try { + if (editing) await contentApi.updateColumn(editing.id, payload); + else await contentApi.createColumn(payload); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteColumn(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onToggleDisabled = async (row, hide) => { + try { + await contentApi.updateColumn(row.id, { is_disabled: hide }); + message.success(hide ? "已隐藏(前台不显示)" : "已显示"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const modalTitle = (() => { + if (editing) return `编辑 · ${editing.name}`; + if (createParentId) { + return `添加子项 · 上级「${nameById.get(createParentId) || ""}」`; + } + return `新建一级栏目 · ${section.label}`; + })(); + + const columns = [ + { + title: "栏目名称", + dataIndex: "name", + render: (name, row) => ( + + {name} + {row.parent ? ( + 子栏目 + ) : row.children?.length ? ( + 含子项 · {row.children.length} + ) : ( + 一级 + )} + + ), + }, + { + title: "简介", + dataIndex: "description", + ellipsis: true, + width: 160, + render: (v) => v || "—", + }, + { + title: "页面模板", + dataIndex: "template_id", + width: 220, + ellipsis: true, + render: (id, row) => { + const label = getTemplateLabel(id); + const url = getTemplateById(id)?.url || row.path; + return ( + + {label} + {url ? ( + + {url} + + ) : null} + + ); + }, + }, + { + title: "排序", + dataIndex: "sort", + width: 72, + }, + { + title: "前台显示", + dataIndex: "is_disabled", + width: 100, + render: (disabled, row) => ( + onToggleDisabled(row, !on)} + /> + ), + }, + { + title: "操作", + width: 260, + render: (_, row) => ( + + {!row.parent ? ( + + ) : null} + + onDelete(row.id)} + > + + + + ), + }, + ]; + + return ( +
+ + 填写名称、简介、注释、显示位置与排序;用「页面模板」决定前台跳转到哪一类页(路径由模板自动写入,不可手改)。可在{" "} + 模板预览 对照效果。 + + } + /> + + ({ + key: s.key, + label: s.label, + children: ( +
+
+ + {s.hint} + + +
+
+ + ), + }))} + /> + + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + width={720} + > + + {!editing && createParentId ? ( + + ) : null} + + {editing?.parent ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + +
+ + + + p.template_id !== c.template_id}> + {({ getFieldValue }) => { + const tid = getFieldValue("template_id"); + return ( + + + + ); + }} + +
+
+ + + + + + +
+ + ); +} diff --git a/src/pages/admin/ContactAdminPage.jsx b/src/pages/admin/ContactAdminPage.jsx new file mode 100644 index 0000000..dbdbd79 --- /dev/null +++ b/src/pages/admin/ContactAdminPage.jsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { Button, Form, Input, Spin, message } from "antd"; +import { contentApi } from "@/services"; + +export default function ContactAdminPage() { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [record, setRecord] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const data = await contentApi.getContact(); + if (data?.id) { + setRecord(data); + form.setFieldsValue(data); + } else { + setRecord(null); + form.resetFields(); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onSubmit = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + if (record?.id) await contentApi.updateContact(record.id, values); + else await contentApi.createContact(values); + message.success("保存成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } finally { + setSaving(false); + } + }; + + return ( + +
+

服务咨询

+ + + + + + + + + + + + + +

媒体合作

+ + + + + + + + + + + + + +

STAR

+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/pages/admin/CopyrightAdminPage.jsx b/src/pages/admin/CopyrightAdminPage.jsx new file mode 100644 index 0000000..1edee74 --- /dev/null +++ b/src/pages/admin/CopyrightAdminPage.jsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { Button, Form, Input, Spin, message } from "antd"; +import { contentApi } from "@/services"; + +export default function CopyrightAdminPage() { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [record, setRecord] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const data = await contentApi.getCopyright(); + if (data?.id) { + setRecord(data); + form.setFieldsValue(data); + } else { + setRecord(null); + form.resetFields(); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onSubmit = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + if (record?.id) await contentApi.updateCopyright(record.id, values); + else await contentApi.createCopyright(values); + message.success("保存成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + + + + + + + + + + + + + +
+ ); +} diff --git a/src/pages/admin/DashboardPage.jsx b/src/pages/admin/DashboardPage.jsx new file mode 100644 index 0000000..6a2166d --- /dev/null +++ b/src/pages/admin/DashboardPage.jsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Card, Col, Row, Statistic, Typography } from "antd"; +import { contentApi } from "@/services"; + +const { Title } = Typography; + +export default function DashboardPage() { + const [stats, setStats] = useState({ + articles: 0, + organizes: 0, + news: 0, + columns: 0, + }); + + useEffect(() => { + (async () => { + const [articles, organizes, news, columns] = await Promise.all([ + contentApi.listArticles({ limit: 1 }), + contentApi.listOrganizes({ limit: 1 }), + contentApi.listNews({ limit: 1 }), + contentApi.listColumns({ limit: 1 }), + ]); + setStats({ + articles: articles.count || 0, + organizes: organizes.count || 0, + news: news.count || 0, + columns: columns.count || 0, + }); + })(); + }, []); + + return ( +
+ 概览 + +
+ + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/admin/KeywordsAdminPage.jsx b/src/pages/admin/KeywordsAdminPage.jsx new file mode 100644 index 0000000..d249044 --- /dev/null +++ b/src/pages/admin/KeywordsAdminPage.jsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Popconfirm, Space, Table, message } from "antd"; +import { contentApi } from "@/services"; +import { SchemaFormModal, keywordsSchema } from "@/formily"; + +export default function KeywordsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listKeywords({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + setOpen(true); + }; + + const initialValues = useCallback(() => { + if (editing) return editing; + return { is_show: true, search_hot: 0 }; + }, [editing]); + + const onSubmit = async (values) => { + setSaving(true); + try { + if (editing) await contentApi.updateKeyword(editing.id, values); + else await contentApi.createKeyword(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + throw err; + } finally { + setSaving(false); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteKeyword(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
(v ? "是" : "否"), + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onSubmit={onSubmit} + confirmLoading={saving} + /> + + ); +} diff --git a/src/pages/admin/LinksAdminPage.jsx b/src/pages/admin/LinksAdminPage.jsx new file mode 100644 index 0000000..3659f45 --- /dev/null +++ b/src/pages/admin/LinksAdminPage.jsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Popconfirm, Space, Table, message } from "antd"; +import { contentApi } from "@/services"; +import { SchemaFormModal, linksSchema } from "@/formily"; + +export default function LinksAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listLinks({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + setOpen(true); + }; + + const initialValues = useCallback(() => { + if (editing) return editing; + return { sort: 0, is_disabled: false }; + }, [editing]); + + const onSubmit = async (values) => { + setSaving(true); + try { + if (editing) await contentApi.updateLink(editing.id, values); + else await contentApi.createLink(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + throw err; + } finally { + setSaving(false); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteLink(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onSubmit={onSubmit} + confirmLoading={saving} + /> + + ); +} diff --git a/src/pages/admin/LoginPage.jsx b/src/pages/admin/LoginPage.jsx new file mode 100644 index 0000000..aca4fec --- /dev/null +++ b/src/pages/admin/LoginPage.jsx @@ -0,0 +1,47 @@ +import { Button, Card, ConfigProvider, Form, Input, Typography, message } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +const { Title } = Typography; + +export default function LoginPage() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const onFinish = async (values) => { + const result = await dispatch.user.login(values); + if (result === "error") return; + message.success("登录成功"); + navigate("/admin"); + }; + + return ( + +
+ + 后台登录 +
+ + + + + + + + +
+
+
+ ); +} diff --git a/src/pages/admin/MagazineCategoriesAdminPage.jsx b/src/pages/admin/MagazineCategoriesAdminPage.jsx new file mode 100644 index 0000000..8bd81a7 --- /dev/null +++ b/src/pages/admin/MagazineCategoriesAdminPage.jsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function MagazineCategoriesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listMagazineCategories({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ sort: 0, is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue(row); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateMagazineCategory(editing.id, values); + else await contentApi.createMagazineCategory(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteMagazineCategory(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const parentOptions = rows + .filter((r) => !editing || r.id !== editing.id) + .map((r) => ({ value: r.id, label: r.name })); + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + +
catName(v), + }, + { + title: "学科", + render: (_, row) => { + const list = row.subjects || []; + if (!list.length) return "-"; + return list + .map((s) => (typeof s === "object" ? s.name : subjects.find((x) => x.id === s)?.name || s)) + .join("、"); + }, + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + centered + styles={schemaModalBodyStyles} + > + + + + + + ({ value: s.id, label: s.name }))} + placeholder="选择学科" + /> + + + + + + + + + + + + + + ); +} diff --git a/src/pages/admin/MembersAdminPage.jsx b/src/pages/admin/MembersAdminPage.jsx new file mode 100644 index 0000000..4aa0b4b --- /dev/null +++ b/src/pages/admin/MembersAdminPage.jsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + Modal, + Popconfirm, + Radio, + Space, + Switch, + Table, + message, +} from "antd"; +import { userApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function MembersAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchVip, setBatchVip] = useState(true); + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async (keyword = search) => { + setLoading(true); + try { + const res = await userApi.listMembers({ + limit: 200, + search: keyword || undefined, + }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ is_vip: false, is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue({ ...row, password: undefined }); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const payload = { ...values }; + if (!payload.password) delete payload.password; + try { + if (editing) await userApi.updateMember(editing.id, payload); + else await userApi.createMember(payload); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await userApi.deleteMember(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onBatchVip = async () => { + if (!selectedRowKeys.length) { + message.warning("请先选择用户"); + return; + } + try { + await userApi.batchMemberVip({ ids: selectedRowKeys, is_vip: batchVip }); + message.success("批量更新成功"); + setSelectedRowKeys([]); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+
+ + { + setSearch(v); + load(v); + }} + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + setBatchVip(e.target.value)} + optionType="button" + options={[ + { label: "设置 VIP", value: true }, + { label: "取消 VIP", value: false }, + ]} + /> + +
+
(v ? "是" : "否"), + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/src/pages/admin/NewsAdminPage.jsx b/src/pages/admin/NewsAdminPage.jsx new file mode 100644 index 0000000..ecd88ee --- /dev/null +++ b/src/pages/admin/NewsAdminPage.jsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function NewsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listNews({ limit: 100 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + setOpen(true); + }; + + const openEdit = async (row) => { + const detail = await contentApi.getNews(row.id); + setEditing(detail); + form.setFieldsValue(detail); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateNews(editing.id, values); + else await contentApi.createNews(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteNews(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + width={720} + destroyOnClose + > +
+ + + + + + + + + + + + + +
+ + ); +} diff --git a/src/pages/admin/OrganizeCategoriesAdminPage.jsx b/src/pages/admin/OrganizeCategoriesAdminPage.jsx new file mode 100644 index 0000000..3ab1f29 --- /dev/null +++ b/src/pages/admin/OrganizeCategoriesAdminPage.jsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function OrganizeCategoriesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listOrganizeCategories({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ sort: 0, is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue(row); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateOrganizeCategory(editing.id, values); + else await contentApi.createOrganizeCategory(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteOrganizeCategory(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const parentOptions = rows + .filter((r) => !editing || r.id !== editing.id) + .map((r) => ({ value: r.id, label: r.name })); + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + +
catName(v), + }, + { title: "排序", dataIndex: "rank", width: 90 }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > + + + + + + + + + + + + + + ); +} diff --git a/src/pages/admin/PageGuideAdminPage.jsx b/src/pages/admin/PageGuideAdminPage.jsx new file mode 100644 index 0000000..8df4f71 --- /dev/null +++ b/src/pages/admin/PageGuideAdminPage.jsx @@ -0,0 +1,155 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Space, Tag, Typography } from "antd"; +import { ExportOutlined } from "@ant-design/icons"; +import { PAGE_GUIDE_PAGES, getPageGuideByKey } from "@/config/pageGuide"; + +const { Text, Title } = Typography; + +function WireframeRegion({ region, active, onHover, onLeave, onClick }) { + return ( + + ); +} + +function WireframeCanvas({ page, hoveredId, setHoveredId, onRegionClick }) { + const { layout, regions } = page; + + return ( +
+ {regions.map((region) => ( + setHoveredId(null)} + onClick={onRegionClick} + /> + ))} +
+ ); +} + +export default function PageGuideAdminPage() { + const navigate = useNavigate(); + const [pageKey, setPageKey] = useState(PAGE_GUIDE_PAGES[0].key); + const [hoveredId, setHoveredId] = useState(null); + + const page = useMemo(() => getPageGuideByKey(pageKey), [pageKey]); + const hovered = page.regions.find((r) => r.id === hoveredId); + + const onRegionClick = (region) => { + if (region.adminPath) navigate(region.adminPath); + }; + + return ( +
+
+
+ + 页面导览 + + + 线框示意前台布局,悬停查看对应后台模块,点击跳转编辑。 + +
+ + {hovered && {hovered.adminLabel}} + + +
+ +
+
+ {PAGE_GUIDE_PAGES.map((p) => { + const selected = p.key === pageKey; + return ( + + ); + })} +
+ +
+
+ + 当前:{page.label} + ({page.publicPath}) + + {page.regions.length} 个可编辑版块 +
+ +
+
+
+ ); +} diff --git a/src/pages/admin/ProfessorsAdminPage.jsx b/src/pages/admin/ProfessorsAdminPage.jsx new file mode 100644 index 0000000..9e55b91 --- /dev/null +++ b/src/pages/admin/ProfessorsAdminPage.jsx @@ -0,0 +1,850 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Avatar, + Button, + Collapse, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, + message, +} from "antd"; +import { + DownOutlined, + PlusOutlined, + UpOutlined, + UserAddOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { contentApi } from "@/services"; +import { MediaUploadBase, schemaModalBodyStyles } from "@/formily"; + +const { Text, Paragraph, Title } = Typography; + +/** 从任职列表提取展示用标签 */ +function membershipTags(memberships = []) { + return memberships.map((m) => { + const td = m.term_detail; + const group = td?.workgroup_name || "工作组"; + const term = td?.name || ""; + const role = m.role ? ` · ${m.role}` : ""; + return { + key: m.id, + label: term ? `${group} / ${term}${role}` : `${group}${role}`, + }; + }); +} + +function termLabel(t) { + if (!t) return ""; + if (t.workgroup_name && t.name && t.workgroup_name !== t.name) { + return `${t.workgroup_name} · ${t.name}`; + } + return t.name || t.workgroup_name || `届次 #${t.id}`; +} + +export default function ProfessorsAdminPage() { + const [sectionLoading, setSectionLoading] = useState(false); + const [tableLoading, setTableLoading] = useState(false); + /** 上方手风琴:当前届次下的专家预览 */ + const [sectionRows, setSectionRows] = useState([]); + /** 下方表格:独立数据与筛选 */ + const [rows, setRows] = useState([]); + const [terms, setTerms] = useState([]); + const [groups, setGroups] = useState([]); + /** 顶部整块是否展开 */ + const [sectionOpen, setSectionOpen] = useState(true); + /** 当前届次 Tab(根届次,parent 为空) */ + const [rootTermId, setRootTermId] = useState(null); + /** 手风琴展开的分委会 key */ + const [activePanel, setActivePanel] = useState(undefined); + /** 表格独立筛选 */ + const [tableSearch, setTableSearch] = useState(""); + const [tableTermId, setTableTermId] = useState(undefined); + const [tableWorkgroupId, setTableWorkgroupId] = useState(undefined); + + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + /** 新建时预挂的届次 id(组内添加) */ + const [createInTermId, setCreateInTermId] = useState(null); + const [form] = Form.useForm(); + + const [memOpen, setMemOpen] = useState(false); + const [memProfessor, setMemProfessor] = useState(null); + const [memberships, setMemberships] = useState([]); + const [memEditing, setMemEditing] = useState(null); + const [memForm] = Form.useForm(); + + const rootTerms = useMemo( + () => terms.filter((t) => t.parent == null), + [terms], + ); + + const childTerms = useMemo( + () => + rootTermId == null + ? [] + : terms + .filter((t) => t.parent === rootTermId) + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id), + [terms, rootTermId], + ); + + /** 手风琴面板:有分委会用分委会;否则用当前根届次本身 */ + const accordionTerms = useMemo(() => { + if (childTerms.length) return childTerms; + const root = terms.find((t) => t.id === rootTermId); + return root ? [root] : []; + }, [childTerms, terms, rootTermId]); + + const termOptionsAll = useMemo( + () => + terms.map((t) => ({ + value: t.id, + label: `${termLabel(t)}${t.is_current ? "(现任)" : ""}`, + parent: t.parent, + workgroup: t.workgroup, + is_current: t.is_current, + })), + [terms], + ); + + const loadMeta = async () => { + const [termRes, groupRes] = await Promise.all([ + contentApi.listWorkGroupTerms({ limit: 200 }), + contentApi.listWorkGroups({ limit: 200 }), + ]); + const termList = termRes.results || []; + setTerms(termList); + setGroups(groupRes.results || []); + const roots = termList.filter((t) => t.parent == null); + const preferred = + roots.find((t) => t.is_current) || roots[0] || termList[0]; + if (preferred) { + setRootTermId(preferred.id); + return preferred.id; + } + return null; + }; + + /** 仅刷新上方手风琴数据 */ + const loadSection = async (rootId) => { + if (rootId == null) { + setSectionRows([]); + return; + } + setSectionLoading(true); + try { + const res = await contentApi.listProfessors({ + limit: 200, + term: rootId, + }); + setSectionRows(res.results || []); + } finally { + setSectionLoading(false); + } + }; + + /** 仅刷新下方表格(使用表格自己的筛选条件) */ + const loadTable = async (overrides = {}) => { + const search = + overrides.search !== undefined ? overrides.search : tableSearch; + const term = overrides.term !== undefined ? overrides.term : tableTermId; + const workgroup = + overrides.workgroup !== undefined + ? overrides.workgroup + : tableWorkgroupId; + setTableLoading(true); + try { + const params = { limit: 200 }; + if (search) params.search = search; + if (term != null) params.term = term; + if (workgroup != null) params.workgroup = workgroup; + const res = await contentApi.listProfessors(params); + setRows(res.results || []); + } finally { + setTableLoading(false); + } + }; + + useEffect(() => { + (async () => { + const tid = await loadMeta(); + await Promise.all([loadSection(tid), loadTable()]); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** 某届次下的成员(手风琴预览) */ + const membersOfTerm = (termId) => + sectionRows.filter((p) => + (p.memberships || []).some( + (m) => m.term === termId || m.term_detail?.id === termId, + ), + ); + + const onTabChange = (key) => { + const id = Number(key); + setRootTermId(id); + setActivePanel(undefined); + loadSection(id); + }; + + const onPanelChange = (key) => { + setActivePanel(key || undefined); + }; + + const onTableSearch = (value) => { + const v = (value || "").trim(); + setTableSearch(v); + loadTable({ search: v }); + }; + + const onTableTermChange = (value) => { + setTableTermId(value); + loadTable({ term: value }); + }; + + const onTableWorkgroupChange = (value) => { + setTableWorkgroupId(value); + loadTable({ workgroup: value }); + }; + + const resetTableFilters = () => { + setTableSearch(""); + setTableTermId(undefined); + setTableWorkgroupId(undefined); + loadTable({ search: "", term: undefined, workgroup: undefined }); + }; + + const groupOptions = useMemo( + () => groups.map((g) => ({ value: g.id, label: g.name })), + [groups], + ); + + const openCreate = () => { + setEditing(null); + setCreateInTermId(null); + form.resetFields(); + form.setFieldsValue({ + position: 0, + is_disabled: false, + term: undefined, + membership_role: "", + }); + setOpen(true); + }; + + const openCreateInTerm = (termId) => { + const tid = termId; + if (tid == null) { + message.info("请先选择届次或分委会"); + return; + } + setEditing(null); + setCreateInTermId(tid); + form.resetFields(); + form.setFieldsValue({ + position: 0, + is_disabled: false, + term: tid, + membership_role: "", + }); + setOpen(true); + }; + + const openEdit = async (row) => { + const detail = await contentApi.getProfessor(row.id); + setEditing(detail); + setCreateInTermId(null); + form.setFieldsValue({ + ...detail, + term: undefined, + membership_role: "", + }); + setOpen(true); + }; + + /** 保存/删除后同时刷新上方预览与下方表格 */ + const refreshList = async () => { + await Promise.all([loadSection(rootTermId), loadTable()]); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const { term, membership_role, ...profFields } = values; + try { + if (editing) { + await contentApi.updateProfessor(editing.id, profFields); + message.success("保存成功"); + } else { + const created = await contentApi.createProfessor(profFields); + const professorId = created?.id; + if (term && professorId) { + await contentApi.createWorkGroupMembership({ + professor: professorId, + term, + role: membership_role || "", + }); + message.success("专家已创建并加入工作组"); + } else { + message.success("专家已创建,可稍后通过「任职」加入工作组"); + } + } + setOpen(false); + refreshList(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteProfessor(id); + message.success("删除成功"); + refreshList(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const openMemberships = async (row) => { + setMemProfessor(row); + setMemEditing(null); + memForm.resetFields(); + if (tableTermId != null) { + memForm.setFieldsValue({ term: tableTermId }); + } + const res = await contentApi.listWorkGroupMemberships({ + professor: row.id, + limit: 100, + }); + setMemberships(res.results || []); + setMemOpen(true); + }; + + const onMemSubmit = async () => { + const values = await memForm.validateFields(); + try { + const payload = { ...values, professor: memProfessor.id }; + if (memEditing) { + await contentApi.updateWorkGroupMembership(memEditing.id, payload); + } else { + await contentApi.createWorkGroupMembership(payload); + } + message.success("保存成功"); + memForm.resetFields(); + setMemEditing(null); + const res = await contentApi.listWorkGroupMemberships({ + professor: memProfessor.id, + limit: 100, + }); + setMemberships(res.results || []); + refreshList(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onMemDelete = async (id) => { + try { + await contentApi.deleteWorkGroupMembership(id); + message.success("删除成功"); + const res = await contentApi.listWorkGroupMemberships({ + professor: memProfessor.id, + limit: 100, + }); + setMemberships(res.results || []); + refreshList(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const modalTitle = (() => { + if (editing) return `编辑专家 · ${editing.name}`; + if (createInTermId != null) { + const t = terms.find((x) => x.id === createInTermId); + return `在「${termLabel(t)}」中添加专家`; + } + return "新建专家"; + })(); + + const columns = [ + { title: "ID", dataIndex: "id", width: 64 }, + { + title: "姓名", + dataIndex: "name", + width: 140, + render: (name, row) => ( + + } + size="small" + /> + {name} + + ), + }, + { title: "身份", dataIndex: "role", width: 100, ellipsis: true }, + { title: "职称", dataIndex: "job", width: 100, ellipsis: true }, + { + title: "工作组任职", + dataIndex: "memberships", + render: (list) => { + const tags = membershipTags(list); + if (!tags.length) return 未加入; + return ( + + {tags.slice(0, 4).map((t) => ( + + {t.label} + + ))} + {tags.length > 4 ? +{tags.length - 4} : null} + + ); + }, + }, + { + title: "操作", + width: 220, + fixed: "right", + render: (_, row) => ( + + + + onDelete(row.id)}> + + + + ), + }, + ]; + + const collapseItems = accordionTerms.map((ct) => { + // 无分委会时面板即根届次,展示本届全部;有分委会则按任职挂到该分委会 + const isRootOnlyPanel = childTerms.length === 0 && ct.id === rootTermId; + const members = isRootOnlyPanel ? sectionRows : membersOfTerm(ct.id); + return { + key: String(ct.id), + label: ( +
+ + {ct.name} + + {members.length} 人 + + + +
+ ), + children: ( +
+ {members.map((p) => ( + + ))} + {/* + 占位:在该分委会内添加专家 */} + +
+ ), + }; + }); + + const tabItems = rootTerms.map((t) => ({ + key: String(t.id), + label: termLabel(t), + })); + + return ( +
+ + + {/* 上:届次 Tab + 分委会手风琴(参考前台专业委员会) */} +
+
+ + 专业委员会 + +
+ + {sectionOpen ? ( +
+ {rootTerms.length ? ( + + ) : ( + + 暂无届次,请先在「工作组届次」中维护。 + + )} + + {sectionLoading ? ( + + 加载中… + + ) : accordionTerms.length ? ( + + ) : rootTerms.length ? ( + + 当前届次下暂无分委会;可到「工作组届次」添加下级分委会,或使用下方「+」在本届添加专家。 + + ) : null} +
+ ) : null} +
+ + {/* 下:专家表格(独立筛选,不受上方点击影响) */} +
+
+ + { + if (!e.target.value) onTableSearch(""); + }} + /> + + {(tableSearch || + tableTermId != null || + tableWorkgroupId != null) && ( + + )} + + +
+ +
+ + + setOpen(false)} + onOk={onSubmit} + okText="保存" + cancelText="取消" + width={640} + destroyOnClose + centered + styles={schemaModalBodyStyles} + > +
+ {!editing && createInTermId != null ? ( + t.id === createInTermId), + )}」`} + description="届次已预填,可填写组内职务。" + /> + ) : null} + {!editing && createInTermId == null ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {!editing ? ( + <> + + + + ) : null + } + + + ) : null} + +
+ + setMemOpen(false)} + footer={null} + width={720} + destroyOnClose + centered + styles={schemaModalBodyStyles} + > + + 为已有专家加入或调整工作组任职。 + +
+ row.term_detail + ? `${row.term_detail.workgroup_name || ""} · ${row.term_detail.name}` + : row.term, + }, + { title: "组内职务", dataIndex: "role" }, + { + title: "操作", + width: 140, + render: (_, row) => ( + + + onMemDelete(row.id)} + > + + + + ), + }, + ]} + /> + + + + + + + + {memEditing ? ( + + ) : null} + + + + + + ); +} diff --git a/src/pages/admin/PublicationsAdminPage.jsx b/src/pages/admin/PublicationsAdminPage.jsx new file mode 100644 index 0000000..7afd339 --- /dev/null +++ b/src/pages/admin/PublicationsAdminPage.jsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Switch, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function PublicationsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listPublications({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ is_disabled: false }); + setOpen(true); + }; + + const openEdit = async (row) => { + const detail = await contentApi.getPublication(row.id); + setEditing(detail); + form.setFieldsValue(detail); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updatePublication(editing.id, values); + else await contentApi.createPublication(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deletePublication(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + width={720} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/src/pages/admin/ReportsAdminPage.jsx b/src/pages/admin/ReportsAdminPage.jsx new file mode 100644 index 0000000..98db3a4 --- /dev/null +++ b/src/pages/admin/ReportsAdminPage.jsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Popconfirm, Space, Table, message } from "antd"; +import { contentApi } from "@/services"; +import { SchemaFormModal, reportsSchema } from "@/formily"; + +export default function ReportsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listReports({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + setOpen(true); + }; + + const initialValues = useCallback(() => { + if (editing) return editing; + return { is_disabled: false }; + }, [editing]); + + const onSubmit = async (values) => { + setSaving(true); + try { + if (editing) await contentApi.updateReport(editing.id, values); + else await contentApi.createReport(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + throw err; + } finally { + setSaving(false); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteReport(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onSubmit={onSubmit} + confirmLoading={saving} + /> + + ); +} diff --git a/src/pages/admin/SpecialtiesAdminPage.jsx b/src/pages/admin/SpecialtiesAdminPage.jsx new file mode 100644 index 0000000..f536941 --- /dev/null +++ b/src/pages/admin/SpecialtiesAdminPage.jsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Popconfirm, Space, Table, message } from "antd"; +import { contentApi } from "@/services"; +import { SchemaFormModal, specialtiesSchema } from "@/formily"; + +export default function SpecialtiesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listSpecialties({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + setOpen(true); + }; + + const initialValues = useCallback(() => { + if (editing) return editing; + return { position: 0, is_disabled: false }; + }, [editing]); + + const onSubmit = async (values) => { + setSaving(true); + try { + if (editing) await contentApi.updateSpecialty(editing.id, values); + else await contentApi.createSpecialty(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + throw err; + } finally { + setSaving(false); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteSpecialty(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + onSubmit={onSubmit} + confirmLoading={saving} + /> + + ); +} diff --git a/src/pages/admin/StaffAdminPage.jsx b/src/pages/admin/StaffAdminPage.jsx new file mode 100644 index 0000000..5170192 --- /dev/null +++ b/src/pages/admin/StaffAdminPage.jsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + Modal, + Popconfirm, + Space, + Switch, + Table, + message, +} from "antd"; +import { userApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function StaffAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await userApi.listStaff({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ is_active: true, is_superuser: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue({ ...row, password: "" }); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const payload = { ...values }; + if (!payload.password) delete payload.password; + try { + if (editing) await userApi.updateStaff(editing.id, payload); + else await userApi.createStaff(payload); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || err?.username?.[0] || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await userApi.deleteStaff(id); + message.success("已禁用该账号"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + return ( +
+ +
(v ? "是" : "否"), + }, + { + title: "启用", + dataIndex: "is_active", + width: 80, + render: (v) => (v ? "是" : "否"), + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/src/pages/admin/StarRulesAdminPage.jsx b/src/pages/admin/StarRulesAdminPage.jsx new file mode 100644 index 0000000..a8fae97 --- /dev/null +++ b/src/pages/admin/StarRulesAdminPage.jsx @@ -0,0 +1,404 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Switch, + Table, + Tag, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +/** 从接口错误中提取可读文案 */ +function errText(err) { + if (!err) return "操作失败"; + if (typeof err === "string") return err; + if (err.detail) { + return typeof err.detail === "string" + ? err.detail + : JSON.stringify(err.detail); + } + const parts = []; + for (const [key, val] of Object.entries(err)) { + if (key === "detail") continue; + const text = Array.isArray(val) ? val.join(";") : String(val); + parts.push(text); + } + return parts.join(";") || "操作失败"; +} + +export default function StarRulesAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [bandOpen, setBandOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [activeRule, setActiveRule] = useState(null); + const [bands, setBands] = useState([]); + const [editingBand, setEditingBand] = useState(null); + const [form] = Form.useForm(); + const [bandForm] = Form.useForm(); + + const sortedBands = useMemo( + () => + [...bands].sort( + (a, b) => Number(a.star_low) - Number(b.star_low) || a.id - b.id, + ), + [bands], + ); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listStarRules({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue(row); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateStarRule(editing.id, values); + else await contentApi.createStarRule(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(errText(err)); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteStarRule(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(errText(err)); + } + }; + + const refreshBands = async (rule) => { + const detail = await contentApi.getStarRule(rule.id); + setBands(detail.bands || []); + setActiveRule(detail); + }; + + const openBands = async (rule) => { + setEditingBand(null); + bandForm.resetFields(); + bandForm.setFieldsValue({ rule: rule.id, is_disabled: false }); + setBandOpen(true); + await refreshBands(rule); + }; + + const openBandCreate = () => { + setEditingBand(null); + bandForm.resetFields(); + bandForm.setFieldsValue({ rule: activeRule.id, is_disabled: false }); + }; + + const openBandEdit = (band) => { + setEditingBand(band); + bandForm.setFieldsValue({ + ...band, + star_low: Number(band.star_low), + star_high: Number(band.star_high), + star: Number(band.star), + }); + }; + + /** 前端预检:与后端规则一致,便于即时提示 */ + const validateBandLocal = (values) => { + const low = Number(values.star_low); + const high = Number(values.star_high); + const star = Number(values.star); + if (Number.isNaN(low) || Number.isNaN(high) || Number.isNaN(star)) { + return "请填写完整的最低分、最高分与星级"; + } + if (low < 0 || high < 0) return "分数不能为负数"; + if (low > high) return "最高分不能低于最低分"; + if (star < 0.5 || star > 5) return "星级须在 0.5~5 之间"; + if (Math.abs(star * 2 - Math.round(star * 2)) > 1e-9) { + return "星级须为 0.5 的倍数(如 1、1.5、2)"; + } + const siblings = bands.filter( + (b) => + !b.is_disabled && + (!editingBand || b.id !== editingBand.id), + ); + if (siblings.some((b) => Number(b.star) === star)) { + return `该规则下已存在星级 ${star} 的分档`; + } + for (const other of siblings) { + const oLow = Number(other.star_low); + const oHigh = Number(other.star_high); + if (low <= oHigh && oLow <= high) { + return `分数区间与已有分档 ${other.star_low}~${other.star_high}(${other.star} 星)重叠`; + } + } + return null; + }; + + const onBandSubmit = async () => { + const values = await bandForm.validateFields(); + const localErr = validateBandLocal(values); + if (localErr) { + message.error(localErr); + return; + } + try { + if (editingBand) await contentApi.updateRuleBand(editingBand.id, values); + else await contentApi.createRuleBand({ ...values, rule: activeRule.id }); + message.success("分档已保存"); + await refreshBands(activeRule); + setEditingBand(null); + bandForm.resetFields(); + bandForm.setFieldsValue({ rule: activeRule.id, is_disabled: false }); + load(); + } catch (err) { + message.error(errText(err)); + } + }; + + const onBandDelete = async (id) => { + try { + await contentApi.deleteRuleBand(id); + message.success("删除成功"); + await refreshBands(activeRule); + load(); + } catch (err) { + message.error(errText(err)); + } + }; + + return ( +
+ +
row.bands?.length ?? 0, + }, + { + title: "状态", + width: 90, + render: (_, row) => + row.is_disabled ? ( + 已禁用 + ) : ( + 启用 + ), + }, + { + title: "操作", + width: 240, + render: (_, row) => ( + + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + +
+ + setBandOpen(false)} + centered + styles={schemaModalBodyStyles} + footer={null} + width={780} + destroyOnClose + > + +
+ row.is_disabled ? 禁用 : 启用, + }, + { + title: "操作", + width: 140, + render: (_, row) => ( + + + onBandDelete(row.id)} + > + + + + ), + }, + ]} + /> + + + + + ({ + validator(_, value) { + const low = getFieldValue("star_low"); + if ( + value != null && + low != null && + Number(value) < Number(low) + ) { + return Promise.reject(new Error("不能低于最低分")); + } + return Promise.resolve(); + }, + }), + ]} + > + + + + + + + + + + + + {editingBand && ( + + )} + + + + + + ); +} diff --git a/src/pages/admin/SubjectsAdminPage.jsx b/src/pages/admin/SubjectsAdminPage.jsx new file mode 100644 index 0000000..a387608 --- /dev/null +++ b/src/pages/admin/SubjectsAdminPage.jsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + message, +} from "antd"; +import { contentApi } from "@/services"; +import { schemaModalBodyStyles } from "@/formily"; + +export default function SubjectsAdminPage() { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const res = await contentApi.listSubjects({ limit: 200 }); + setRows(res.results || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ sort: 0, is_disabled: false }); + setOpen(true); + }; + + const openEdit = (row) => { + setEditing(row); + form.setFieldsValue(row); + setOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + try { + if (editing) await contentApi.updateSubject(editing.id, values); + else await contentApi.createSubject(values); + message.success("保存成功"); + setOpen(false); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const onDelete = async (id) => { + try { + await contentApi.deleteSubject(id); + message.success("删除成功"); + load(); + } catch (err) { + message.error(err?.detail || "操作失败"); + } + }; + + const parentOptions = rows + .filter((r) => !editing || r.id !== editing.id) + .map((r) => ({ value: r.id, label: r.name })); + + return ( +
+ +
( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + +
row.workgroup_name || groupName(v), + }, + { title: "届次", dataIndex: "name" }, + { + title: "同届上级", + dataIndex: "parent", + render: (v) => (v ? termLabel(v) : "-"), + }, + { + title: "年份", + width: 120, + render: (_, row) => + [row.start_year, row.end_year].filter(Boolean).join("-") || "-", + }, + { + title: "现任", + dataIndex: "is_current", + width: 70, + render: (v) => (v ? "是" : "否"), + }, + { + title: "操作", + width: 160, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]} + /> + setOpen(false)} + centered + styles={schemaModalBodyStyles} + onOk={onSubmit} + okText="保存" + cancelText="取消" + width={640} + destroyOnClose + > + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/dev/ArticleRelationsPage.jsx b/src/pages/dev/ArticleRelationsPage.jsx new file mode 100644 index 0000000..1c862a9 --- /dev/null +++ b/src/pages/dev/ArticleRelationsPage.jsx @@ -0,0 +1,417 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +/** 目标态:Subject / OrganizeCategory / MagazineCategory 独立模型 */ +const NODES = { + article: { + id: "article", + label: "Article", + sub: "文献", + kind: "hub", + table: "content_article", + badge: "主体", + relation: "指南 / 共识文献主表", + fields: "title, type(1指南/2共识), year, score", + note: "已去掉 Article.categories;score 无 FK", + tier: "center", + }, + organize: { + id: "organize", + label: "Organize", + sub: "制订机构", + kind: "direct", + table: "content_organize", + badge: "直接 M2M", + relation: "Article ↔ Organize", + fields: "中间表 content_article_organizes", + note: "实体机构,不是类别字典", + tier: "direct", + }, + magazine: { + id: "magazine", + label: "Magazine", + sub: "杂志", + kind: "direct", + table: "content_magazine", + badge: "直接 M2M", + relation: "Article ↔ Magazine", + fields: "中间表 content_article_magazines", + note: "实体杂志", + tier: "direct", + }, + subject: { + id: "subject", + label: "Subject", + sub: "学科", + kind: "direct", + table: "content_subject", + badge: "直接 M2M", + relation: "Article ↔ Subject", + fields: "中间表 content_article_subjects", + note: "原 Category(type=学科),现独立表", + tier: "direct", + }, + orgcat: { + id: "orgcat", + label: "OrganizeCategory", + sub: "机构类别", + kind: "indirect", + table: "content_organizecategory", + badge: "FK", + relation: "Organize.category → OrganizeCategory", + fields: "FK Organize.category_id", + note: "原 Category(type=机构)", + tier: "indirect", + }, + magcat: { + id: "magcat", + label: "MagazineCategory", + sub: "杂志类别", + kind: "indirect", + table: "content_magazinecategory", + badge: "FK", + relation: "Magazine.category → MagazineCategory", + fields: "FK Magazine.category_id", + note: "原 Category(type=杂志)", + tier: "indirect", + }, + workgroup: { + id: "workgroup", + label: "WorkGroup", + sub: "工作组模板", + kind: "indirect", + table: "content_workgroup", + badge: "模板树", + relation: "WorkGroup.parent 自关联", + fields: "委员会 → 分委会", + note: "换届时作结构模板", + tier: "indirect", + }, + term: { + id: "term", + label: "WorkGroupTerm", + sub: "届次", + kind: "indirect", + table: "content_workgroupterm", + badge: "届内树", + relation: "Term → WorkGroup;Term.parent 同届", + fields: "name, start_year, end_year, is_current", + note: "每届下可有分委会", + tier: "indirect", + }, + professor: { + id: "professor", + label: "Professor", + sub: "专家", + kind: "indirect", + table: "content_professor", + badge: "挂届", + relation: "Professor ↔ WorkGroupTerm", + fields: "through WorkGroupMembership.role", + note: "不再挂机构", + tier: "indirect", + }, + column: { + id: "column", + label: "Column", + sub: "导航栏目", + kind: "none", + table: "content_column", + badge: "独立", + relation: "与 Article 无表关联", + fields: "parent 自关联可选", + note: "不再经 Category 挂文献", + tier: "none", + }, + starrule: { + id: "starrule", + label: "StarRule", + sub: "评分规则", + kind: "none", + table: "content_starrule", + badge: "无外键", + relation: "与 Article 无表关联", + fields: "RuleBand.rule → StarRule", + note: "文献只存 score 数值", + tier: "none", + }, +}; + +const EDGES = [ + { from: "article", to: "organize", label: "M2M", kind: "direct" }, + { from: "article", to: "magazine", label: "M2M", kind: "direct" }, + { from: "article", to: "subject", label: "M2M", kind: "direct" }, + { from: "magazine", to: "subject", label: "M2M", kind: "indirect" }, + { from: "organize", to: "orgcat", label: "FK", kind: "indirect" }, + { from: "magazine", to: "magcat", label: "FK", kind: "indirect" }, + { from: "term", to: "workgroup", label: "FK", kind: "indirect" }, + { from: "professor", to: "term", label: "M2M", kind: "indirect" }, +]; + +const KIND_STYLE = { + hub: { fill: "#dbeafe", stroke: "#2563eb", badge: "bg-blue-100 text-blue-800" }, + direct: { fill: "#ede9fe", stroke: "#7c3aed", badge: "bg-violet-100 text-violet-800" }, + indirect: { fill: "#ffedd5", stroke: "#ea580c", badge: "bg-orange-100 text-orange-800" }, + none: { fill: "#f1f5f9", stroke: "#64748b", badge: "bg-slate-100 text-slate-700" }, +}; + +function nodeSize(n) { + const w = Math.max(n.label.length * 8.2, n.sub.length * 7) + 32; + return { w: Math.min(Math.max(w, 110), 168), h: 54 }; +} + +function layout(width, height, scope) { + const cx = width / 2; + const cy = height / 2; + const pos = { article: { x: cx, y: cy } }; + const directIds = ["organize", "magazine", "subject"]; + const r1 = Math.min(width, height) * (scope === "direct" ? 0.3 : 0.26); + directIds.forEach((id, i) => { + const a = -Math.PI / 2 + (i * (2 * Math.PI)) / 3; + pos[id] = { + x: cx + Math.cos(a) * r1, + y: cy + Math.sin(a) * r1 * 0.9, + }; + }); + if (scope === "all") { + pos.orgcat = { + x: Math.max(90, pos.organize.x - 40), + y: Math.min(height - 50, pos.organize.y + 100), + }; + pos.magcat = { + x: Math.min(width - 90, pos.magazine.x + 40), + y: Math.min(height - 50, pos.magazine.y + 100), + }; + pos.workgroup = { x: 110, y: 56 }; + pos.term = { x: 280, y: 56 }; + pos.professor = { x: 460, y: 56 }; + pos.column = { x: width - 200, y: 48 }; + pos.starrule = { x: width - 90, y: 48 }; + } + return pos; +} + +function edgePoint(from, to, sizeFrom, sizeTo) { + const dx = to.x - from.x; + const dy = to.y - from.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len; + const uy = dy / len; + const tOut = Math.min(sizeFrom.w / 2 / Math.abs(ux || 0.001), sizeFrom.h / 2 / Math.abs(uy || 0.001)); + const tIn = Math.min(sizeTo.w / 2 / Math.abs(ux || 0.001), sizeTo.h / 2 / Math.abs(uy || 0.001)); + return { + x1: from.x + ux * tOut, + y1: from.y + uy * tOut, + x2: to.x - ux * tIn, + y2: to.y - uy * tIn, + mx: (from.x + to.x) / 2, + my: (from.y + to.y) / 2 - 8, + }; +} + +function visibleNode(id, scope) { + const n = NODES[id]; + if (!n) return false; + if (scope === "all") return true; + return n.tier === "center" || n.tier === "direct"; +} + +/** 临时开发页:Article 目标态关系图(全屏) */ +export default function ArticleRelationsPage() { + const stageRef = useRef(null); + const [size, setSize] = useState({ width: 960, height: 640 }); + const [scope, setScope] = useState("direct"); + const [selected, setSelected] = useState("article"); + + useEffect(() => { + const el = stageRef.current; + if (!el) return undefined; + const update = () => + setSize({ + width: Math.max(el.clientWidth || 320, 280), + height: Math.max(el.clientHeight || 240, 240), + }); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const { width, height } = size; + const pos = useMemo(() => layout(width, height, scope), [width, height, scope]); + const sizes = useMemo(() => { + const map = {}; + Object.keys(NODES).forEach((id) => { + map[id] = nodeSize(NODES[id]); + }); + return map; + }, []); + + const activeId = visibleNode(selected, scope) ? selected : "article"; + const detail = NODES[activeId]; + const detailStyle = KIND_STYLE[detail.kind]; + + const edges = EDGES.filter((e) => { + if (!visibleNode(e.from, scope) || !visibleNode(e.to, scope)) return false; + if (scope === "direct" && e.kind !== "direct") return false; + return true; + }); + + return ( +
+
+
+
+ 临时路由 · /dev/article-er · 已落库 +
+

Article 关系图

+
+
+
+ + +
+
+ + 主体 + + + 直接 + + + 间接 + + + 无关联 + +
+
+
+ +
+
+ + {edges.map((e) => { + const p = edgePoint(pos[e.from], pos[e.to], sizes[e.from], sizes[e.to]); + return ( + + + + {e.label} + + + ); + })} + {Object.keys(NODES) + .filter((id) => visibleNode(id, scope)) + .map((id) => { + const n = NODES[id]; + const p = pos[id]; + const s = sizes[id]; + const style = KIND_STYLE[n.kind]; + const active = id === activeId; + return ( + setSelected(id)} + onKeyDown={(ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + setSelected(id); + } + }} + > + + + {n.label} + + + {n.sub} + + + ); + })} + +
+ + +
+
+ ); +} diff --git a/src/pages/public/AboutPage.jsx b/src/pages/public/AboutPage.jsx new file mode 100644 index 0000000..8961357 --- /dev/null +++ b/src/pages/public/AboutPage.jsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import { contentApi } from "@/services"; + +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +export default function AboutPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [data, setData] = useState({}); + + useEffect(() => { + let alive = true; + contentApi + .getAbout() + .then((res) => alive && setData(res || {})) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, []); + + const FALLBACK_WORK = [ + { + name: t("about.rdGroup"), + content: t("about.rdGroupDesc"), + url: "/assets/img/about/yanfa.png", + }, + { + name: t("about.verifyGroup"), + content: t("about.verifyGroupDesc"), + url: "/assets/img/about/yanzheng.png", + }, + { + name: t("about.rateGroup"), + content: t("about.rateGroupDesc"), + url: "/assets/img/about/pingji.png", + }, + ]; + + const workRows = useMemo(() => { + const list = + Array.isArray(data.work_group) && data.work_group.length + ? data.work_group + : FALLBACK_WORK; + return chunk(list, 3); + }, [data.work_group, t]); + const productRows = useMemo( + () => chunk(Array.isArray(data.product) ? data.product : [], 4), + [data.product] + ); + + return ( +
+
+

+ {t("nav.about")} +

+
+
+ {loading ? ( +
{t("common.loading")}
+ ) : ( + <> +
+ +

+ {data.growing_path || data.introduction || t("about.empty")} +

+
+ +
+ +
+ {workRows.length === 0 && ( +

{data.introduction}

+ )} + {workRows.map((row, i) => ( +
+ {row.map((item, j) => ( +
+ {item.name +
+ {item.name || item.title} +
+

+ {item.content || item.desc} +

+
+ ))} +
+ ))} +
+
+ +
+ +

', + }} + /> +
+ +
+ +
+ {productRows.length === 0 && ( +

{t("about.empty")}

+ )} + {productRows.map((row, i) => ( +
+ {row.map((item, j) => { + const inner = ( + {item.name + ); + return item.link ? ( + + {inner} + + ) : ( +
{inner}
+ ); + })} +
+ ))} +
+
+ + )} +
+
+ ); +} diff --git a/src/pages/public/ArticleDetailPage.jsx b/src/pages/public/ArticleDetailPage.jsx new file mode 100644 index 0000000..b87a94b --- /dev/null +++ b/src/pages/public/ArticleDetailPage.jsx @@ -0,0 +1,392 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import ArticleEngageActions from "@/components/site/ArticleEngageActions"; +import StarRating from "@/components/site/StarRating"; +import { contentApi } from "@/services"; +import { resolveStarLevel } from "@/utils/starLevel"; + +/** 模块级去重:React StrictMode 开发环境会双挂载 useEffect,避免同 id 记两次阅读 */ +const recordedReadIds = new Set(); + +/** 判断文本是否含 HTML 标签(简介可能为带标签文本) */ +function looksLikeHtml(text) { + return typeof text === "string" && /<\/?[a-z][\s\S]*>/i.test(text); +} + +/** 去掉 HTML 标签并还原常见实体 */ +function stripHtml(html) { + return String(html || "") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim(); +} + +/** + * 解析 introduction 中「

键:值

」结构。 + * 成功时返回 [{ label, value }];无法识别则返回空数组。 + */ +function parseIntroductionSections(html) { + if (!html || typeof html !== "string") return []; + const sections = []; + const re = /]*>\s*]*>([\s\S]*?)<\/b>([\s\S]*?)<\/p>/gi; + let match; + while ((match = re.exec(html)) !== null) { + const label = stripHtml(match[1]).replace(/[::]\s*$/, ""); + const value = stripHtml(match[2]); + if (label && value) sections.push({ label, value }); + } + return sections; +} + +function MetaRow({ label, children }) { + if (children == null || children === "" || children === "-") return null; + return ( +
+ {label}: + {children} +
+ ); +} + +function TagChip({ children }) { + return ( + + {children} + + ); +} + +export default function ArticleDetailPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const id = params.get("id"); + const memberToken = useSelector((s) => s.member?.token); + const [loading, setLoading] = useState(true); + const [article, setArticle] = useState(null); + const [comments, setComments] = useState([]); + const [starLevel, setStarLevel] = useState(null); + const [scoreRank, setScoreRank] = useState(null); + const [liked, setLiked] = useState(false); + const [collected, setCollected] = useState(false); + + useEffect(() => { + if (!id) return; + let alive = true; + setLoading(true); + + // 同步占位并立即上报,避免 StrictMode 双挂载重复计数 + if (!recordedReadIds.has(id)) { + recordedReadIds.add(id); + contentApi + .recordArticleRead(id) + .then((r) => { + if (r?.read_num == null) return; + // 不依赖本次 effect 的 alive(StrictMode 首次 cleanup 会将其置 false) + setArticle((prev) => + prev && String(prev.id) === String(id) + ? { ...prev, read_num: r.read_num } + : prev, + ); + }) + .catch(() => { + recordedReadIds.delete(id); + }); + } + + contentApi + .getArticle(id) + .then(async (res) => { + if (!alive) return; + // 若 record-read 已先返回,保留较大的阅读量,避免被详情旧值覆盖 + setArticle((prev) => { + if ( + prev && + String(prev.id) === String(res.id) && + (prev.read_num ?? 0) > (res.read_num ?? 0) + ) { + return { ...res, read_num: prev.read_num }; + } + return res; + }); + + const tasks = [ + contentApi + .listArticleComments({ article: id, limit: 50 }) + .then((c) => alive && setComments(c.results || [])) + .catch(() => alive && setComments([])), + contentApi + .listStarRules({ limit: 50 }) + .then((r) => { + if (!alive) return; + const rules = r.results || (Array.isArray(r) ? r : []); + setStarLevel(resolveStarLevel(res.score, rules, res.year)); + }) + .catch(() => alive && setStarLevel(null)), + ]; + + // 分数排名:同类型同年份列表中的位次(按 score 降序) + if (res.score != null) { + const query = { limit: 200 }; + if (res.type) query.type = res.type; + if (res.year) query.year = res.year; + tasks.push( + contentApi + .articleRanking(query) + .then((rankRes) => { + if (!alive) return; + const rows = rankRes?.results || (Array.isArray(rankRes) ? rankRes : []); + const idx = rows.findIndex((row) => String(row.id) === String(res.id)); + setScoreRank(idx >= 0 ? idx + 1 : null); + }) + .catch(() => alive && setScoreRank(null)), + ); + } + + await Promise.all(tasks); + }) + .catch(() => { + if (alive) setArticle(null); + }) + .finally(() => alive && setLoading(false)); + + return () => { + alive = false; + }; + }, [id]); + + useEffect(() => { + if (!id || !memberToken) { + setLiked(false); + setCollected(false); + return; + } + let alive = true; + contentApi + .myArticleEngagement([id]) + .then((res) => { + if (!alive) return; + const likedSet = new Set((res?.liked || []).map(String)); + const collectedSet = new Set((res?.collected || []).map(String)); + setLiked(likedSet.has(String(id))); + setCollected(collectedSet.has(String(id))); + }) + .catch(() => { + if (!alive) return; + setLiked(false); + setCollected(false); + }); + return () => { + alive = false; + }; + }, [id, memberToken]); + + const flagTags = useMemo(() => { + const raw = article?.flags; + if (!Array.isArray(raw)) return []; + return raw + .map((f) => (typeof f === "string" ? f : f?.name || f?.label || String(f))) + .filter(Boolean); + }, [article]); + + const introSections = useMemo( + () => parseIntroductionSections(article?.introduction), + [article?.introduction], + ); + + if (loading) { + return
{t("common.loading")}
; + } + if (!article) { + return
{t("common.error")}
; + } + + const introHtml = looksLikeHtml(article.introduction); + + return ( +
+ + + {t("actions.back")} + + +
+ + {article.type_name || + (article.type === 1 + ? t("article.guideline") + : t("article.consensus"))} + + {(article.subjects || []).map((s) => ( + {s.name} + ))} +
+ +

{article.title}

+ + {/* STAR 星级 / 排名(前台不展示原始得分) */} +
+ + {t("article.starLevel")}: + + + {scoreRank != null && ( + + {t("article.scoreRank")}:{" "} + #{scoreRank} + {article.year ? ` (${article.year})` : ""} + + )} +
+ + {/* 互动:点赞/收藏需登录,分享免登录 */} +
+ { + const { liked: nextLiked, collected: nextCollected, ...counters } = + partial; + if (Object.keys(counters).length) { + setArticle((prev) => (prev ? { ...prev, ...counters } : prev)); + } + if (nextLiked != null) setLiked(nextLiked); + if (nextCollected != null) setCollected(nextCollected); + }} + /> +
+ +
+ {article.year || "-"} + {article.push_date || "-"} + + {(article.subjects || []).map((s) => s.name).join(", ") || "-"} + + + {(article.organizes || []).map((o) => o.name).join(", ") || "-"} + + + {(article.magazines || []).map((m) => m.name).join(", ") || "-"} + + + {article.url ? ( + + {article.url} + + ) : ( + "-" + )} + +
+ + {/* 各种 tag:flags + 关联维度 */} + {(flagTags.length > 0 || + (article.subjects || []).length > 0 || + (article.organizes || []).length > 0 || + (article.magazines || []).length > 0) && ( +
+

{t("article.tags")}

+
+ {flagTags.map((tag) => ( + {tag} + ))} + {(article.subjects || []).map((s) => ( + {s.name} + ))} + {(article.organizes || []).map((o) => ( + {o.name} + ))} + {(article.magazines || []).map((m) => ( + {m.name} + ))} +
+
+ )} + +

{t("article.introduction")}

+ {introSections.length >= 2 ? ( +
+ {introSections.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+ ) : introHtml ? ( +
+ ) : ( +

+ {article.introduction || "-"} +

+ )} + +

{t("article.content")}

+
+ + {/* 读者点评(公众接口仅返回已审核通过) */} +

{t("article.comments")}

+ {!comments.length ? ( +

{t("article.commentsEmpty")}

+ ) : ( +
    + {comments.map((c) => ( +
  • +
    + + {c.member_name || t("article.anonymous")} + {c.member_organize ? ( + + {c.member_organize} + + ) : null} + + + {t("article.commentStar")}: {c.star ?? "-"} + {c.created_at + ? ` · ${String(c.created_at).slice(0, 10)}` + : ""} + +
    +

    + {c.content || "-"} +

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/pages/public/ArticleListPage.jsx b/src/pages/public/ArticleListPage.jsx new file mode 100644 index 0000000..761be1d --- /dev/null +++ b/src/pages/public/ArticleListPage.jsx @@ -0,0 +1,577 @@ +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import ArticleEngageActions from "@/components/site/ArticleEngageActions"; +import EmptyState from "@/components/site/EmptyState"; +import StarRating from "@/components/site/StarRating"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { contentApi } from "@/services"; +import { cn } from "@/lib/utils"; +import { resolveStarLevel } from "@/utils/starLevel"; + +function toggleInList(list, id) { + return list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; +} + +function toCsv(ids) { + return ids.length ? ids.join(",") : undefined; +} + +export default function ArticleListPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const memberToken = useSelector((s) => s.member?.token); + const typeFromUrl = params.get("type") + ? Number(params.get("type")) + : undefined; + const [loading, setLoading] = useState(true); + const [data, setData] = useState({ results: [], count: 0 }); + const [keyword, setKeyword] = useState(params.get("q") || ""); + const [years, setYears] = useState([]); + const [startYear, setStartYear] = useState(undefined); + const [endYear, setEndYear] = useState(undefined); + const [selectedTypes, setSelectedTypes] = useState(() => + typeFromUrl ? [typeFromUrl] : [], + ); + const [ordering, setOrdering] = useState("-score"); + const [subjects, setSubjects] = useState([]); + const [organizes, setOrganizes] = useState([]); + const [magazines, setMagazines] = useState([]); + const [facets, setFacets] = useState({ total: 0, type: {}, types: [] }); + const [starRules, setStarRules] = useState([]); + const [engagement, setEngagement] = useState({ + liked: new Set(), + collected: new Set(), + }); + const [selectedSubjects, setSelectedSubjects] = useState([]); + const [selectedOrganizes, setSelectedOrganizes] = useState([]); + const [selectedMagazines, setSelectedMagazines] = useState([]); + const [openPanels, setOpenPanels] = useState({ + year: true, + type: true, + subject: true, + organize: true, + magazine: true, + }); + + useEffect(() => { + setSelectedTypes(typeFromUrl ? [typeFromUrl] : []); + }, [typeFromUrl]); + + useEffect(() => { + Promise.all([ + contentApi.listSubjects({ limit: 200 }), + contentApi.listOrganizes({ limit: 200 }), + contentApi.listMagazines({ limit: 200 }), + contentApi.articleFacets(), + contentApi.listStarRules({ limit: 50 }), + ]).then(([subjectRes, organizeRes, magazineRes, facetRes, ruleRes]) => { + setSubjects(subjectRes.results || []); + setOrganizes(organizeRes.results || []); + setMagazines(magazineRes.results || []); + const ys = (facetRes?.years || []).map(Number).filter(Boolean); + setYears(ys); + if (ys.length) { + setEndYear((prev) => prev ?? ys[0]); + setStartYear((prev) => prev ?? ys[ys.length - 1]); + } + const typeRows = Array.isArray(facetRes?.types) + ? facetRes.types + : [ + { + id: 1, + name: t("article.guideline"), + count: facetRes?.type?.["1"] ?? facetRes?.type?.[1] ?? 0, + }, + { + id: 2, + name: t("article.consensus"), + count: facetRes?.type?.["2"] ?? facetRes?.type?.[2] ?? 0, + }, + ]; + setFacets({ + total: facetRes?.total || 0, + type: facetRes?.type || {}, + types: typeRows, + }); + setStarRules(ruleRes.results || (Array.isArray(ruleRes) ? ruleRes : [])); + }); + }, []); + + useEffect(() => { + let alive = true; + setLoading(true); + const query = { + limit: 50, + ordering, + search: keyword || undefined, + type: toCsv(selectedTypes), + year_gte: startYear, + year_lte: endYear, + subject: toCsv(selectedSubjects), + organize: toCsv(selectedOrganizes), + magazine: toCsv(selectedMagazines), + }; + contentApi + .listArticles(query) + .then((res) => { + if (!alive) return; + setData(res); + }) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [ + keyword, + ordering, + selectedTypes, + startYear, + endYear, + selectedSubjects, + selectedOrganizes, + selectedMagazines, + ]); + + // 登录后批量拉取当前页点赞/收藏状态 + useEffect(() => { + const ids = (data.results || []).map((r) => r.id); + if (!memberToken || !ids.length) { + setEngagement({ liked: new Set(), collected: new Set() }); + return; + } + let alive = true; + contentApi + .myArticleEngagement(ids) + .then((res) => { + if (!alive) return; + setEngagement({ + liked: new Set((res?.liked || []).map(String)), + collected: new Set((res?.collected || []).map(String)), + }); + }) + .catch(() => { + if (alive) setEngagement({ liked: new Set(), collected: new Set() }); + }); + return () => { + alive = false; + }; + }, [memberToken, data.results]); + + const toggle = (key) => + setOpenPanels((p) => ({ + ...p, + [key]: !p[key], + })); + + const tagsOf = (item) => [ + ...(item.subjects || []).map((s) => ({ key: `s-${s.id}`, name: s.name })), + ...(item.organizes || []).map((o) => ({ key: `o-${o.id}`, name: o.name })), + ...(item.magazines || []).map((m) => ({ key: `m-${m.id}`, name: m.name })), + ]; + + return ( +
+
+ {/* 左侧筛选:外层白底随列表等高,避免收起后左下大片空白;内层 sticky 保持筛选可见 */} + + + {/* 右侧列表 */} +
+
+
+ + setKeyword(e.target.value)} + placeholder={t("search.placeholderShort")} + className="h-9 flex-1 border-0 bg-transparent px-0 shadow-none focus:border-0 focus:ring-0" + /> +
+ + + {t("common.total", { n: data.count || 0 })} + +
+ + {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+ ))} +
+ ) : !(data.results || []).length ? ( +
+ +
+ ) : ( +
+ {(data.results || []).map((item) => { + const tags = tagsOf(item); + const starLevel = resolveStarLevel( + item.score, + starRules, + item.year, + ); + return ( +
+ + +
+ + {item.type_name || + (item.type === 1 + ? t("article.guideline") + : t("article.consensus"))} + + {item.year != null && item.year !== "" && ( + + {item.year} + + )} + + + STAR + + + +
+

+ {item.title} +

+ {!!tags.length && ( +
+ {tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} + +
+ { + const { liked, collected, ...counters } = partial; + if (Object.keys(counters).length) { + setData((prev) => ({ + ...prev, + results: (prev.results || []).map((row) => + row.id === item.id + ? { ...row, ...counters } + : row, + ), + })); + } + if (liked != null || collected != null) { + setEngagement((prev) => { + const nextLiked = new Set(prev.liked); + const nextCollected = new Set(prev.collected); + const key = String(item.id); + if (liked != null) { + if (liked) nextLiked.add(key); + else nextLiked.delete(key); + } + if (collected != null) { + if (collected) nextCollected.add(key); + else nextCollected.delete(key); + } + return { + liked: nextLiked, + collected: nextCollected, + }; + }); + } + }} + /> +
+
+ ); + })} +
+ )} +
+
+
+ ); +} + +function FilterCheckboxList({ + items, + selectedIds, + onToggle, + onSelectAll, + allLabel, + allCount, + showCount = false, +}) { + const allChecked = selectedIds.length === 0; + + return ( + <> + {allLabel && ( + + )} + {!items.length ? ( +
—
+ ) : ( + items.map((item) => { + const checked = selectedIds.includes(item.id); + const count = item.article_count; + return ( + + ); + }) + )} + + ); +} + +function FilterPanel({ title, icon, open, onToggle, children }) { + return ( +
+ + {open && ( +
{children}
+ )} +
+ ); +} diff --git a/src/pages/public/ContactPage.jsx b/src/pages/public/ContactPage.jsx new file mode 100644 index 0000000..5c092f1 --- /dev/null +++ b/src/pages/public/ContactPage.jsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { contentApi } from "@/services"; + +export default function ContactPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [data, setData] = useState({}); + + useEffect(() => { + let alive = true; + contentApi + .getContact() + .then((res) => alive && setData(res || {})) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, []); + + const blocks = [ + { + title: t("contact.serve"), + desc: data.serve_desc, + email: data.serve_email, + phone: data.serve_phone, + }, + { + title: t("contact.media"), + desc: data.media_desc, + email: data.media_email, + phone: data.media_phone, + }, + { + title: t("contact.star"), + desc: data.star_desc, + email: data.star_email, + phone: data.star_phone, + }, + ]; + + return ( +
+
+

+ {t("contact.title")} +

+
+ +
+ {loading ? ( +
{t("common.loading")}
+ ) : ( +
+ {blocks.map((b) => ( +
+
+ +

+ {b.title} +

+
+ {b.desc ? ( +

+ {b.desc} +

+ ) : ( +
+ )} + {b.email && ( +
+ + {b.email} +
+ )} + {b.phone && ( +
+ + {b.phone} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/public/HomePage.jsx b/src/pages/public/HomePage.jsx new file mode 100644 index 0000000..650cb1e --- /dev/null +++ b/src/pages/public/HomePage.jsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import HomeCarousel from "@/components/site/HomeCarousel"; +import { FALLBACK_BANNERS } from "@/config/nav"; +import { contentApi } from "@/services"; + +const FALLBACK_REPORTS = [ + { + id: "r1", + name: "STAR Report 2024", + year: 2024, + logo: "/assets/img/01@2x.jpg", + file_path: "#", + }, + { + id: "r2", + name: "STAR Report 2023", + year: 2023, + logo: "/assets/img/02@2x.jpg", + file_path: "#", + }, + { + id: "r3", + name: "STAR Report 2022", + year: 2022, + logo: "/assets/img/03@2x.jpg", + file_path: "#", + }, + { + id: "r4", + name: "STAR Report 2021", + year: 2021, + logo: "/assets/img/04@2x.jpg", + file_path: "#", + }, +]; + +function formatDate(value) { + if (!value) return ""; + return String(value).slice(0, 10); +} + +export default function HomePage() { + const { t } = useTranslation(); + const [news, setNews] = useState([]); + const [publications, setPublications] = useState([]); + const [reports, setReports] = useState(FALLBACK_REPORTS); + const [banners, setBanners] = useState(FALLBACK_BANNERS); + + useEffect(() => { + let alive = true; + (async () => { + try { + const [newsRes, pubRes, reportRes, bannerRes] = await Promise.all([ + contentApi.listNews({ limit: 8 }), + contentApi.listPublications({ limit: 8 }), + contentApi.listReports({ limit: 20 }), + contentApi.listBanners(), + ]); + if (!alive) return; + setNews(newsRes?.results || []); + setPublications(pubRes?.results || []); + const remote = reportRes?.results || []; + setReports(remote.length ? remote : FALLBACK_REPORTS); + const bannerList = Array.isArray(bannerRes) + ? bannerRes + : bannerRes?.results || []; + const enabled = bannerList.filter((b) => !b.is_disabled); + setBanners(enabled.length ? enabled : FALLBACK_BANNERS); + } catch { + if (alive) { + setReports(FALLBACK_REPORTS); + setBanners(FALLBACK_BANNERS); + } + } + })(); + return () => { + alive = false; + }; + }, []); + + return ( +
+ + + {/* 新闻通知 + 资源中心 */} +
+
+ +
    + {news.map((item) => ( +
  • + + + {formatDate(item.publication_time)} + +   {item.title} + +
  • + ))} + {!news.length && ( +
  • {t("news.empty")}
  • + )} +
+
+
+ +
    + {publications.map((item) => ( +
  • + + + {formatDate(item.publication_time)} + +   {item.title} + +
  • + ))} + {!publications.length && ( +
  • + {t("publication.empty")} +
  • + )} +
+
+
+ + {/* 报告下载(对齐国内首页,非指南推荐) */} +
+ + +
+
+ ); +} + +function ReportCarousel({ reports }) { + const scrollerRef = useRef(null); + + const scrollBy = (dir) => { + const el = scrollerRef.current; + if (!el) return; + el.scrollBy({ left: dir * 320, behavior: "smooth" }); + }; + + const onDownload = (item) => { + if (!item.file_path || item.file_path === "#") return; + window.open(item.file_path, "_blank"); + }; + + return ( +
+ + + +
+ {reports.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/public/LegalDocPage.jsx b/src/pages/public/LegalDocPage.jsx new file mode 100644 index 0000000..06bc79c --- /dev/null +++ b/src/pages/public/LegalDocPage.jsx @@ -0,0 +1,39 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +/** + * 法律文案展示页(用户协议 / 隐私政策)。 + * @param {{ title: string, body: string }} props + */ +export default function LegalDocPage({ title, body }) { + const { t } = useTranslation(); + + return ( +
+
+

+ {title} +

+
+ +
+
+
+ {body} +
+

+ + {t("actions.back")} + +

+
+
+
+ ); +} diff --git a/src/pages/public/MemberLoginPage.jsx b/src/pages/public/MemberLoginPage.jsx new file mode 100644 index 0000000..9617b68 --- /dev/null +++ b/src/pages/public/MemberLoginPage.jsx @@ -0,0 +1,272 @@ +import { useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import BrandLogo from "@/components/site/BrandLogo"; +import DialCodeSelect from "@/components/site/DialCodeSelect"; +import { DEFAULT_DIAL_CODE } from "@/data/dialCodes"; +import { buildInternationalPhone } from "@/utils/phone"; + +export default function MemberLoginPage() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [mode, setMode] = useState("login"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [agreed, setAgreed] = useState(false); + const [dialCode, setDialCode] = useState(DEFAULT_DIAL_CODE); + const [form, setForm] = useState({ + name: "", + phone: "", + organize: "", + password: "", + }); + + const redirectTo = searchParams.get("redirect") || "/index"; + + const onChange = (key) => (e) => { + setForm((prev) => ({ ...prev, [key]: e.target.value })); + if (error) setError(""); + }; + + const fullPhone = () => buildInternationalPhone(dialCode, form.phone); + + const onSubmit = async (e) => { + e.preventDefault(); + if (mode === "register" && !agreed) { + setError(t("member.agreeRequired")); + return; + } + const phone = fullPhone(); + if (!phone) { + setError(t("member.phoneInvalid")); + return; + } + setLoading(true); + setError(""); + try { + const result = + mode === "login" + ? await dispatch.member.login({ + phone, + password: form.password, + }) + : await dispatch.member.register({ + name: form.name.trim(), + phone, + organize: form.organize.trim(), + password: form.password, + }); + if (result === "error") { + setError(t("member.authFailed")); + return; + } + navigate(redirectTo, { replace: true }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

+ {mode === "login" + ? t("member.loginTitle") + : t("member.registerTitle")} +

+

{t("member.subtitle")}

+
+ +
+ {mode === "register" && ( + <> + + + + )} + + + + + + {mode === "register" ? ( + + ) : ( +

+ {t("member.agreePrefix")}{" "} + + {t("member.userAgreement")} + {" "} + {t("member.and")}{" "} + + {t("member.privacyPolicy")} + +

+ )} + + {error ?

{error}

: null} + + + +

+ {mode === "login" ? ( + <> + {t("member.noAccount")}{" "} + + + ) : ( + <> + {t("member.hasAccount")}{" "} + + + )} +

+ + +

+ + {t("actions.back")} + +

+
+
+ ); +} diff --git a/src/pages/public/MyCollectionsPage.jsx b/src/pages/public/MyCollectionsPage.jsx new file mode 100644 index 0000000..a2d6028 --- /dev/null +++ b/src/pages/public/MyCollectionsPage.jsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { Link, Navigate, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import ArticleEngageActions from "@/components/site/ArticleEngageActions"; +import EmptyState from "@/components/site/EmptyState"; +import StarRating from "@/components/site/StarRating"; +import { contentApi } from "@/services"; +import { resolveStarLevel } from "@/utils/starLevel"; + +export default function MyCollectionsPage() { + const { t } = useTranslation(); + const location = useLocation(); + const memberToken = useSelector((s) => s.member?.token); + const [loading, setLoading] = useState(true); + const [rows, setRows] = useState([]); + const [starRules, setStarRules] = useState([]); + const [likedIds, setLikedIds] = useState(() => new Set()); + + useEffect(() => { + if (!memberToken) return; + let alive = true; + setLoading(true); + Promise.all([ + contentApi.myArticleCollections({ limit: 100 }), + contentApi.listStarRules({ limit: 50 }), + ]) + .then(([colRes, ruleRes]) => { + if (!alive) return; + const list = colRes.results || []; + setRows(list); + setStarRules( + ruleRes.results || (Array.isArray(ruleRes) ? ruleRes : []), + ); + const ids = list.map((r) => r.id); + if (!ids.length) { + setLikedIds(new Set()); + return; + } + return contentApi.myArticleEngagement(ids).then((eng) => { + if (!alive) return; + setLikedIds(new Set((eng?.liked || []).map(String))); + }); + }) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [memberToken]); + + if (!memberToken) { + return ( + + ); + } + + return ( +
+
+
+

+ {t("member.myCollections")} +

+

+ {t("common.total", { n: rows.length })} +

+
+ + {loading ? ( +
+ {t("common.loading")} +
+ ) : !rows.length ? ( +
+ +
+ + {t("member.browseArticles")} + +
+
+ ) : ( +
+ {rows.map((item) => { + const starLevel = resolveStarLevel( + item.score, + starRules, + item.year, + ); + return ( +
+ + +
+ + {item.type === 1 + ? t("article.guideline") + : t("article.consensus")} + + {item.year != null && item.year !== "" && ( + + {item.year} + + )} + + + STAR + + + +
+

+ {item.title} +

+ +
+ { + const { liked, collected, ...counters } = partial; + if (Object.keys(counters).length) { + setRows((prev) => + prev.map((row) => + row.id === item.id + ? { ...row, ...counters } + : row, + ), + ); + } + if (liked != null) { + setLikedIds((prev) => { + const next = new Set(prev); + const key = String(item.id); + if (liked) next.add(key); + else next.delete(key); + return next; + }); + } + // 取消收藏后从列表移除 + if (collected === false) { + setRows((prev) => + prev.filter((row) => row.id !== item.id), + ); + } + }} + /> +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/pages/public/NewsDetailPage.jsx b/src/pages/public/NewsDetailPage.jsx new file mode 100644 index 0000000..fc1d331 --- /dev/null +++ b/src/pages/public/NewsDetailPage.jsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { contentApi } from "@/services"; + +export default function NewsDetailPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const id = params.get("id"); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + let alive = true; + contentApi + .getNews(id) + .then((res) => alive && setItem(res)) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [id]); + + if (loading) { + return
{t("common.loading")}
; + } + if (!item) { + return
{t("common.error")}
; + } + + return ( +
+ + + {t("actions.back")} + +

{item.title}

+
{item.publication_time}
+
+
+ ); +} diff --git a/src/pages/public/NewsListPage.jsx b/src/pages/public/NewsListPage.jsx new file mode 100644 index 0000000..7f3d52e --- /dev/null +++ b/src/pages/public/NewsListPage.jsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import { contentApi } from "@/services"; + +export default function NewsListPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [list, setList] = useState([]); + + useEffect(() => { + let alive = true; + contentApi + .listNews({ limit: 50 }) + .then((res) => alive && setList(res.results || [])) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, []); + + return ( +
+ +
    + {loading && ( +
  • {t("common.loading")}
  • + )} + {!loading && + list.map((item) => ( +
  • + + + {String(item.publication_time || "").slice(0, 10)} + +   {item.title} + +
  • + ))} + {!loading && !list.length && ( +
  • {t("news.empty")}
  • + )} +
+
+ ); +} diff --git a/src/pages/public/OrganizeDetailPage.jsx b/src/pages/public/OrganizeDetailPage.jsx new file mode 100644 index 0000000..747c6cf --- /dev/null +++ b/src/pages/public/OrganizeDetailPage.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { contentApi } from "@/services"; + +export default function OrganizeDetailPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const id = params.get("id"); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + let alive = true; + contentApi + .getOrganize(id) + .then((res) => alive && setItem(res)) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [id]); + + if (loading) { + return
{t("common.loading")}
; + } + if (!item) { + return
{t("common.error")}
; + } + + return ( +
+ + + {t("actions.back")} + +

{item.name}

+

{item.description}

+ {item.url && ( + + {item.url} + + )} +
+ ); +} diff --git a/src/pages/public/OrganizeListPage.jsx b/src/pages/public/OrganizeListPage.jsx new file mode 100644 index 0000000..9ba0fe9 --- /dev/null +++ b/src/pages/public/OrganizeListPage.jsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PageBanner from "@/components/site/PageBanner"; +import EmptyState from "@/components/site/EmptyState"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { contentApi } from "@/services"; + +export default function OrganizeListPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [list, setList] = useState([]); + const [categories, setCategories] = useState([{ id: -1, name: t("actions.all") }]); + const [selectId, setSelectId] = useState(-1); + const [keyword, setKeyword] = useState(""); + const [openMap, setOpenMap] = useState({}); + + useEffect(() => { + let alive = true; + (async () => { + try { + const [orgRes, catRes] = await Promise.all([ + contentApi.listOrganizes({ limit: 200 }), + contentApi.listOrganizeCategories({ limit: 50 }), + ]); + if (!alive) return; + const orgs = orgRes.results || []; + const cats = [ + { id: -1, name: t("actions.all") }, + ...(catRes.results || []), + ]; + setList(orgs); + setCategories(cats); + const open = {}; + cats.forEach((c) => { + open[c.id] = true; + }); + setOpenMap(open); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, [t]); + + const groups = useMemo(() => { + const filtered = list.filter((o) => { + if (keyword && !o.name.toLowerCase().includes(keyword.toLowerCase())) { + return false; + } + if (selectId !== -1 && o.category !== selectId) return false; + return true; + }); + + if (selectId !== -1) { + const cat = categories.find((c) => c.id === selectId); + return [{ id: selectId, title: cat?.name || "", value: filtered }]; + } + + const byCategory = new Map(); + filtered.forEach((o) => { + const key = o.category || 0; + if (!byCategory.has(key)) byCategory.set(key, []); + byCategory.get(key).push(o); + }); + + if (byCategory.size <= 1) { + return [{ id: -1, title: t("actions.all"), value: filtered }]; + } + + return [...byCategory.entries()].map(([id, value]) => ({ + id, + title: categories.find((c) => c.id === id)?.name || t("organize.title"), + value, + })); + }, [list, keyword, selectId, categories, t]); + + return ( +
+ + +
+
+ + setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} + /> + + + {t("actions.viewMore")} + + +
+ + {loading ? ( +
{t("common.loading")}
+ ) : !groups.some((g) => g.value.length) ? ( + + ) : ( +
+ {groups.map((group) => ( +
+ + {openMap[group.id] && ( +
+ {group.value.map((o) => ( + + {o.name} + + ))} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/public/PrivacyPolicyPage.jsx b/src/pages/public/PrivacyPolicyPage.jsx new file mode 100644 index 0000000..94da767 --- /dev/null +++ b/src/pages/public/PrivacyPolicyPage.jsx @@ -0,0 +1,6 @@ +import LegalDocPage from "./LegalDocPage"; +import { title, body } from "@/content/privacyPolicy"; + +export default function PrivacyPolicyPage() { + return ; +} diff --git a/src/pages/public/PublicationDetailPage.jsx b/src/pages/public/PublicationDetailPage.jsx new file mode 100644 index 0000000..4966f97 --- /dev/null +++ b/src/pages/public/PublicationDetailPage.jsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { contentApi } from "@/services"; + +export default function PublicationDetailPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const id = params.get("id"); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + let alive = true; + contentApi + .getPublication(id) + .then((res) => alive && setItem(res)) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [id]); + + if (loading) { + return
{t("common.loading")}
; + } + if (!item) { + return
{t("common.error")}
; + } + + return ( +
+ + + {t("actions.back")} + +

{item.title}

+
{item.publication_time}
+ {item.url && ( + + )} +
+
+ ); +} diff --git a/src/pages/public/PublicationListPage.jsx b/src/pages/public/PublicationListPage.jsx new file mode 100644 index 0000000..d4216b3 --- /dev/null +++ b/src/pages/public/PublicationListPage.jsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import { contentApi } from "@/services"; + +export default function PublicationListPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [list, setList] = useState([]); + + useEffect(() => { + let alive = true; + contentApi + .listPublications({ limit: 50 }) + .then((res) => alive && setList(res.results || [])) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, []); + + return ( +
+ +
    + {loading && ( +
  • {t("common.loading")}
  • + )} + {!loading && + list.map((item) => ( +
  • + + + {String(item.publication_time || "").slice(0, 10)} + +   {item.title} + +
  • + ))} + {!loading && !list.length && ( +
  • + {t("publication.empty")} +
  • + )} +
+
+ ); +} diff --git a/src/pages/public/RankingPage.jsx b/src/pages/public/RankingPage.jsx new file mode 100644 index 0000000..c73db82 --- /dev/null +++ b/src/pages/public/RankingPage.jsx @@ -0,0 +1,306 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import StarRating from "@/components/site/StarRating"; +import YearSelect from "@/components/site/YearSelect"; +import EmptyState from "@/components/site/EmptyState"; +import { contentApi } from "@/services"; +import { cn } from "@/lib/utils"; +import { resolveStarLevel } from "@/utils/starLevel"; + +const RANK_META = { + article: { + titleKey: "article.ranking", + fetcher: (p) => contentApi.articleRanking(p), + }, + organize: { + titleKey: "organize.ranking", + fetcher: (p) => contentApi.organizeRanking(p), + }, + magazine: { + titleKey: "magazine.ranking", + fetcher: (p) => contentApi.magazineRanking(p), + }, +}; + +export default function RankingPage({ kind = "article" }) { + const { t } = useTranslation(); + const [params, setParams] = useSearchParams(); + const type = params.get("type") ? Number(params.get("type")) : undefined; + const yearParam = params.get("year"); + const year = yearParam ? Number(yearParam) : undefined; + const [loading, setLoading] = useState(true); + const [rows, setRows] = useState([]); + const [filterOpen, setFilterOpen] = useState(true); + const [subjects, setSubjects] = useState([]); + const [subjectId, setSubjectId] = useState(undefined); + const [years, setYears] = useState([]); + const [starRules, setStarRules] = useState([]); + const didDefaultYear = useRef(false); + + const pageTitle = useMemo(() => { + if (kind !== "article") return t(RANK_META[kind].titleKey); + if (type === 2) return t("nav.chinaConsensusRanking"); + if (params.get("scope") === "tcm") { + return type === 2 + ? t("nav.tcmConsensusRanking") + : t("nav.tcmGuideRanking"); + } + return t("nav.chinaGuideRanking"); + }, [kind, type, params, t]); + + useEffect(() => { + contentApi.listSubjects({ limit: 50 }).then((res) => { + setSubjects(res.results || []); + }); + contentApi.listStarRules({ limit: 50 }).then((res) => { + setStarRules(res.results || (Array.isArray(res) ? res : [])); + }); + contentApi.articleFacets().then((res) => { + const ys = (res?.years || []).map(Number).filter(Boolean); + setYears(ys); + // 首次进入且 URL 无 year:默认最新有数据的年份 + if (!didDefaultYear.current && !params.get("year") && ys.length) { + didDefaultYear.current = true; + const next = new URLSearchParams(params); + next.set("year", String(ys[0])); + setParams(next, { replace: true }); + } else { + didDefaultYear.current = true; + } + }); + }, []); + + useEffect(() => { + let alive = true; + setLoading(true); + const query = { limit: 100 }; + if (year) query.year = year; + if (kind === "article" && type) query.type = type; + if (kind === "article" && subjectId) query.subject = subjectId; + + RANK_META[kind] + .fetcher(query) + .then((res) => { + if (!alive) return; + setRows(res?.results || (Array.isArray(res) ? res : [])); + }) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [kind, type, year, subjectId]); + + const setYear = (y) => { + const next = new URLSearchParams(params); + if (y) next.set("year", String(y)); + else next.delete("year"); + setParams(next); + }; + + return ( +
+ {/* 顶栏:标题 + 年份,叠在浅色条上(对齐线上 time_bar) */} +
+
+
+

{pageTitle}

+ +
+
+ +
+ {/* 分类折叠 */} +
+
+
+ + {t("common.category")} +
+ +
+ {filterOpen && kind === "article" && ( +
+
{t("article.subjects")}
+
+ + {subjects.map((s) => ( + + ))} +
+
+ )} +
+ + + +
+ {loading ? ( +
{t("common.loading")}
+ ) : !rows.length ? ( + + ) : ( +
+ + + + {kind === "article" && ( + <> + + + + + + + )} + {kind === "organize" && ( + <> + + + + + )} + {kind === "magazine" && ( + <> + + + + + )} + + + + {rows.map((row, i) => ( + + + {kind === "article" && ( + <> + + + + + + + )} + {kind === "organize" && ( + <> + + + + + )} + {kind === "magazine" && ( + <> + + + + + )} + + ))} + +
#{t("article.docTitle")}{t("article.subjects")}{t("article.organizes")}{t("article.magazines")}{t("article.starLevel")}{t("organize.title")}{t("common.count")}{t("article.starLevel")}Name{t("common.count")}{t("article.starLevel")}
+ {i + 1} + + + {row.title} + + + {(row.subjects || []).map((s) => s.name).join("、") || + "-"} + + {(row.organizes || []).map((o) => o.name).join("、") || + "-"} + + {(row.magazines || []).map((m) => m.name).join("、") || + "-"} + + + + + {row.name} + + {row.article_count ?? 0} + + {row.name}{row.article_count ?? 0} + +
+ )} +
+
+
+ ); +} diff --git a/src/pages/public/SearchPage.jsx b/src/pages/public/SearchPage.jsx new file mode 100644 index 0000000..fc42aa5 --- /dev/null +++ b/src/pages/public/SearchPage.jsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import { Input } from "@/components/ui/input"; +import { contentApi } from "@/services"; + +export default function SearchPage() { + const { t } = useTranslation(); + const [params, setParams] = useSearchParams(); + const q = params.get("q") || ""; + const [loading, setLoading] = useState(false); + const [keyword, setKeyword] = useState(q); + const [result, setResult] = useState({ + articles: [], + organizes: [], + professors: [], + news: [], + }); + + useEffect(() => { + setKeyword(q); + if (!q) { + setResult({ articles: [], organizes: [], professors: [], news: [] }); + return; + } + let alive = true; + setLoading(true); + contentApi + .searchAll({ q }) + .then((res) => alive && setResult(res || {})) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [q]); + + return ( +
+ +
+ + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") setParams({ q: keyword.trim() }); + }} + placeholder={t("search.placeholder")} + className="h-10 flex-1 border-0 bg-[#eee] px-0 focus:border-0 focus:ring-0" + /> + +
+ +
+ {loading &&
{t("common.loading")}
} + {!loading && ( + <> +
+

+ {t("search.articles")} +

+
    + {(result.articles || []).map((item) => ( +
  • + + {item.title} + +
  • + ))} + {!(result.articles || []).length && ( +
  • {t("search.empty")}
  • + )} +
+
+
+

+ {t("search.organizes")} +

+
    + {(result.organizes || []).map((item) => ( +
  • + + {item.name} + +
  • + ))} + {!(result.organizes || []).length && ( +
  • {t("search.empty")}
  • + )} +
+
+
+

+ {t("search.professors")} +

+
    + {(result.professors || []).map((item) => ( +
  • + + {item.name} + +
  • + ))} + {!(result.professors || []).length && ( +
  • {t("search.empty")}
  • + )} +
+
+
+

{t("search.news")}

+
    + {(result.news || []).map((item) => ( +
  • + + {item.title} + +
  • + ))} + {!(result.news || []).length && ( +
  • {t("search.empty")}
  • + )} +
+
+ + )} +
+
+ ); +} diff --git a/src/pages/public/SpecialistDetailPage.jsx b/src/pages/public/SpecialistDetailPage.jsx new file mode 100644 index 0000000..9836821 --- /dev/null +++ b/src/pages/public/SpecialistDetailPage.jsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { contentApi } from "@/services"; + +export default function SpecialistDetailPage() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const id = params.get("id"); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + let alive = true; + contentApi + .getProfessor(id) + .then((res) => alive && setItem(res)) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [id]); + + if (loading) { + return
{t("common.loading")}
; + } + if (!item) { + return
{t("common.error")}
; + } + + return ( +
+ + + {t("actions.back")} + +

{item.name}

+
+ {[item.role, item.job].filter(Boolean).join(" · ")} +
+ {(item.memberships || []).length > 0 && ( +
    + {item.memberships.map((m) => ( +
  • + {m.term_detail?.workgroup_name || ""} + {m.term_detail?.name ? ` · ${m.term_detail.name}` : ""} + {m.role ? `(${m.role})` : ""} +
  • + ))} +
+ )} +

{item.description}

+ {item.email &&
{item.email}
} +
+ ); +} diff --git a/src/pages/public/SpecialistPage.jsx b/src/pages/public/SpecialistPage.jsx new file mode 100644 index 0000000..27722db --- /dev/null +++ b/src/pages/public/SpecialistPage.jsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PageBanner from "@/components/site/PageBanner"; +import EmptyState from "@/components/site/EmptyState"; +import { contentApi } from "@/services"; + +export default function SpecialistPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [list, setList] = useState([]); + const [terms, setTerms] = useState([]); + const [termId, setTermId] = useState(undefined); + const [openMap, setOpenMap] = useState({}); + + useEffect(() => { + let alive = true; + (async () => { + try { + const termRes = await contentApi.listWorkGroupTerms({ + limit: 100, + is_current: true, + }); + if (!alive) return; + const termRows = termRes.results || []; + setTerms(termRows); + const roots = termRows.filter((x) => !x.parent); + const defaultTerm = roots[0] || termRows[0]; + if (defaultTerm) setTermId(defaultTerm.id); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + if (!termId) return; + let alive = true; + setLoading(true); + contentApi + .listProfessors({ limit: 100, term: termId }) + .then((res) => { + if (!alive) return; + setList(res.results || []); + setOpenMap({}); + }) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [termId]); + + const rootTerms = useMemo( + () => terms.filter((x) => !x.parent), + [terms] + ); + + const childTerms = useMemo( + () => terms.filter((x) => x.parent === termId), + [terms, termId] + ); + + const groups = useMemo(() => { + if (!childTerms.length) { + return [{ id: "all", title: t("nav.workgroup"), value: list }]; + } + return childTerms.map((ct) => ({ + id: ct.id, + title: ct.name, + value: list.filter((p) => + (p.memberships || []).some((m) => m.term === ct.id || m.term_detail?.id === ct.id) + ), + })); + }, [childTerms, list, t]); + + useEffect(() => { + const open = {}; + groups.forEach((g) => { + open[g.id] = true; + }); + setOpenMap(open); + }, [groups]); + + return ( +
+ + +
+ {rootTerms.length > 0 && ( +
+ {rootTerms.map((rt) => ( + + ))} +
+ )} + + {loading ? ( +
{t("common.loading")}
+ ) : !list.length ? ( + + ) : ( +
+ {groups.map((group) => ( +
+ + {openMap[group.id] && ( +
+ {group.value.map((p) => ( + +
+ {p.logo ? ( + {p.name} + ) : ( + + )} +
+
+ {p.name} +
+
+ {p.role || p.job} +
+ + ))} + {!group.value.length && ( +
{t("professor.empty")}
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/public/StatementPage.jsx b/src/pages/public/StatementPage.jsx new file mode 100644 index 0000000..d9cb851 --- /dev/null +++ b/src/pages/public/StatementPage.jsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SectionTitle from "@/components/site/SectionTitle"; +import { contentApi } from "@/services"; + +export default function StatementPage() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [data, setData] = useState({}); + + useEffect(() => { + let alive = true; + contentApi + .getCopyright() + .then((res) => alive && setData(res || {})) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, []); + + return ( +
+
+

+ {t("statement.title")} +

+
+ +
+
+ {loading ? ( + t("common.loading") + ) : ( +
{data.copyright}
+ )} +
+ + {!loading && data.responsibility && ( +
+ +
+ {data.responsibility} +
+
+ )} + + {!loading && data.email && ( +
+ +
+ + {data.email} +
+ {data.remark && ( +

{data.remark}

+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/public/UserAgreementPage.jsx b/src/pages/public/UserAgreementPage.jsx new file mode 100644 index 0000000..a82a91a --- /dev/null +++ b/src/pages/public/UserAgreementPage.jsx @@ -0,0 +1,6 @@ +import LegalDocPage from "./LegalDocPage"; +import { title, body } from "@/content/userAgreement"; + +export default function UserAgreementPage() { + return ; +} diff --git a/src/router.jsx b/src/router.jsx new file mode 100644 index 0000000..61cf9c0 --- /dev/null +++ b/src/router.jsx @@ -0,0 +1,146 @@ +import { createBrowserRouter, Navigate } from "react-router-dom"; + +import PublicLayout from "@/layouts/PublicLayout"; +import AdminLayout from "@/layouts/AdminLayout"; +import RouteErrorPage from "@/pages/RouteErrorPage"; +import HomePage from "@/pages/public/HomePage"; +import ArticleListPage from "@/pages/public/ArticleListPage"; +import ArticleDetailPage from "@/pages/public/ArticleDetailPage"; +import RankingPage from "@/pages/public/RankingPage"; +import OrganizeListPage from "@/pages/public/OrganizeListPage"; +import OrganizeDetailPage from "@/pages/public/OrganizeDetailPage"; +import SpecialistPage from "@/pages/public/SpecialistPage"; +import SpecialistDetailPage from "@/pages/public/SpecialistDetailPage"; +import NewsListPage from "@/pages/public/NewsListPage"; +import NewsDetailPage from "@/pages/public/NewsDetailPage"; +import PublicationListPage from "@/pages/public/PublicationListPage"; +import PublicationDetailPage from "@/pages/public/PublicationDetailPage"; +import AboutPage from "@/pages/public/AboutPage"; +import StatementPage from "@/pages/public/StatementPage"; +import ContactPage from "@/pages/public/ContactPage"; +import SearchPage from "@/pages/public/SearchPage"; +import MemberLoginPage from "@/pages/public/MemberLoginPage"; +import MyCollectionsPage from "@/pages/public/MyCollectionsPage"; +import UserAgreementPage from "@/pages/public/UserAgreementPage"; +import PrivacyPolicyPage from "@/pages/public/PrivacyPolicyPage"; +import LoginPage from "@/pages/admin/LoginPage"; +import DashboardPage from "@/pages/admin/DashboardPage"; +import ArticlesAdminPage from "@/pages/admin/ArticlesAdminPage"; +import ArticleTypesAdminPage from "@/pages/admin/ArticleTypesAdminPage"; +import OrganizesAdminPage from "@/pages/admin/OrganizesAdminPage"; +import ColumnsAdminPage from "@/pages/admin/ColumnsAdminPage"; +import TemplatesPreviewAdminPage from "@/pages/admin/TemplatesPreviewAdminPage"; +import NewsAdminPage from "@/pages/admin/NewsAdminPage"; +import BannersAdminPage from "@/pages/admin/BannersAdminPage"; +import SubjectsAdminPage from "@/pages/admin/SubjectsAdminPage"; +import OrganizeCategoriesAdminPage from "@/pages/admin/OrganizeCategoriesAdminPage"; +import MagazineCategoriesAdminPage from "@/pages/admin/MagazineCategoriesAdminPage"; +import MagazinesAdminPage from "@/pages/admin/MagazinesAdminPage"; +import WorkGroupsAdminPage from "@/pages/admin/WorkGroupsAdminPage"; +import WorkGroupTermsAdminPage from "@/pages/admin/WorkGroupTermsAdminPage"; +import ProfessorsAdminPage from "@/pages/admin/ProfessorsAdminPage"; +import PublicationsAdminPage from "@/pages/admin/PublicationsAdminPage"; +import ReportsAdminPage from "@/pages/admin/ReportsAdminPage"; +import AboutAdminPage from "@/pages/admin/AboutAdminPage"; +import CopyrightAdminPage from "@/pages/admin/CopyrightAdminPage"; +import ContactAdminPage from "@/pages/admin/ContactAdminPage"; +import LinksAdminPage from "@/pages/admin/LinksAdminPage"; +import KeywordsAdminPage from "@/pages/admin/KeywordsAdminPage"; +import SpecialtiesAdminPage from "@/pages/admin/SpecialtiesAdminPage"; +import StarRulesAdminPage from "@/pages/admin/StarRulesAdminPage"; +import ArticleCommentsAdminPage from "@/pages/admin/ArticleCommentsAdminPage"; +import MembersAdminPage from "@/pages/admin/MembersAdminPage"; +import StaffAdminPage from "@/pages/admin/StaffAdminPage"; +import PageGuideAdminPage from "@/pages/admin/PageGuideAdminPage"; +import ArticleRelationsPage from "@/pages/dev/ArticleRelationsPage"; + +const router = createBrowserRouter([ + { + // 全局错误边界:覆盖前台 / 后台 / 开发页 + errorElement: , + children: [ + // 临时开发页:Article 表关系图,上线前删除 + { path: "/dev/article-er", element: }, + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "index", element: }, + { path: "guidelineAndConsensus", element: }, + { path: "fingerpostDetail", element: }, + { path: "guideRanking", element: }, + { path: "magazineRanking", element: }, + { + path: "organizationRanking", + element: , + }, + { path: "indexRanking", element: }, + { path: "organization", element: }, + { path: "organization/detail", element: }, + { path: "specialist", element: }, + { + path: "specialist/specialistDetail", + element: , + }, + { path: "newsList", element: }, + { path: "newsDetail", element: }, + { path: "publication", element: }, + { path: "publicationDetail", element: }, + { path: "about", element: }, + { path: "statement", element: }, + { path: "relation", element: }, + { path: "search", element: }, + { path: "login", element: }, + { path: "myCollections", element: }, + { path: "userAgreement", element: }, + { path: "privacy", element: }, + ], + }, + { path: "/admin/login", element: }, + { + path: "/admin", + element: , + children: [ + { index: true, element: }, + { path: "page-guide", element: }, + { path: "articles", element: }, + { path: "article-types", element: }, + { path: "subjects", element: }, + { path: "organizes", element: }, + { + path: "organize-categories", + element: , + }, + { + path: "magazine-categories", + element: , + }, + { path: "magazines", element: }, + { path: "work-groups", element: }, + { path: "work-group-terms", element: }, + { path: "professors", element: }, + { path: "columns", element: }, + { path: "templates", element: }, + { path: "news", element: }, + { path: "banners", element: }, + { path: "publications", element: }, + { path: "reports", element: }, + { path: "about", element: }, + { path: "copyright", element: }, + { path: "contact", element: }, + { path: "links", element: }, + { path: "keywords", element: }, + { path: "specialties", element: }, + { path: "star-rules", element: }, + { path: "article-comments", element: }, + { path: "members", element: }, + { path: "staff", element: }, + ], + }, + { path: "*", element: }, + ], + }, +]); + +export default router; diff --git a/src/services/content.js b/src/services/content.js new file mode 100644 index 0000000..ee13f50 --- /dev/null +++ b/src/services/content.js @@ -0,0 +1,210 @@ +import { get, post, patch, remove, upload } from "./utils"; + +const unwrap = (data) => { + if (data && Array.isArray(data.results)) return data; + if (Array.isArray(data)) return { count: data.length, results: data }; + return data; +}; + +export const listArticles = (params) => get("/api/articles/", params).then(unwrap); +export const getArticle = (id) => get(`/api/articles/${id}/`); +/** 前台详情阅读上报(与 GET 详情分离,避免重复计数) */ +export const recordArticleRead = (id) => post(`/api/articles/${id}/record-read/`); +/** 会员点赞/取消点赞(需登录) */ +export const toggleArticleLike = (id) => post(`/api/articles/${id}/toggle-like/`); +/** 会员收藏/取消收藏(需登录) */ +export const toggleArticleCollect = (id) => + post(`/api/articles/${id}/toggle-collect/`); +/** 分享计数(同人同文献只计一次;传 visitorKey 便于未登录去重) */ +export const recordArticleShare = (id, visitorKey) => + post(`/api/articles/${id}/record-share/`, { + visitor_key: visitorKey || undefined, + }); +/** 当前会员收藏列表 */ +export const myArticleCollections = (params) => + get("/api/articles/my-collections/", params).then(unwrap); +/** 批量查询点赞/收藏状态,ids 为文献 id 数组 */ +export const myArticleEngagement = (ids) => + get("/api/articles/my-engagement/", { + ids: Array.isArray(ids) ? ids.join(",") : ids, + }); +export const homeArticles = () => get("/api/articles/home/"); +export const articleFacets = (params) => get("/api/articles/facets/", params); +export const articleRanking = (params) => + get("/api/articles/ranking/", params).then(unwrap); +export const createArticle = (data) => post("/api/articles/", data); +export const updateArticle = (id, data) => patch(`/api/articles/${id}/`, data); +export const deleteArticle = (id) => remove(`/api/articles/${id}/`); + +export const listOrganizes = (params) => get("/api/organizes/", params).then(unwrap); +export const getOrganize = (id) => get(`/api/organizes/${id}/`); +export const organizeRanking = (params) => + get("/api/organizes/ranking/", params).then(unwrap); +export const createOrganize = (data) => post("/api/organizes/", data); +export const updateOrganize = (id, data) => patch(`/api/organizes/${id}/`, data); +export const deleteOrganize = (id) => remove(`/api/organizes/${id}/`); + +export const listMagazines = (params) => get("/api/magazines/", params).then(unwrap); +export const magazineRanking = (params) => + get("/api/magazines/ranking/", params).then(unwrap); +export const createMagazine = (data) => post("/api/magazines/", data); +export const updateMagazine = (id, data) => patch(`/api/magazines/${id}/`, data); +export const deleteMagazine = (id) => remove(`/api/magazines/${id}/`); + +export const listWorkGroups = (params) => + get("/api/work-groups/", params).then(unwrap); +export const createWorkGroup = (data) => post("/api/work-groups/", data); +export const updateWorkGroup = (id, data) => patch(`/api/work-groups/${id}/`, data); +export const deleteWorkGroup = (id) => remove(`/api/work-groups/${id}/`); + +export const listWorkGroupTerms = (params) => + get("/api/work-group-terms/", params).then(unwrap); +export const createWorkGroupTerm = (data) => post("/api/work-group-terms/", data); +export const updateWorkGroupTerm = (id, data) => + patch(`/api/work-group-terms/${id}/`, data); +export const deleteWorkGroupTerm = (id) => remove(`/api/work-group-terms/${id}/`); + +export const listWorkGroupMemberships = (params) => + get("/api/work-group-memberships/", params).then(unwrap); +export const createWorkGroupMembership = (data) => + post("/api/work-group-memberships/", data); +export const updateWorkGroupMembership = (id, data) => + patch(`/api/work-group-memberships/${id}/`, data); +export const deleteWorkGroupMembership = (id) => + remove(`/api/work-group-memberships/${id}/`); + +export const listProfessors = (params) => + get("/api/professors/", params).then(unwrap); +export const getProfessor = (id) => get(`/api/professors/${id}/`); +export const createProfessor = (data) => post("/api/professors/", data); +export const updateProfessor = (id, data) => patch(`/api/professors/${id}/`, data); +export const deleteProfessor = (id) => remove(`/api/professors/${id}/`); + +export const listColumns = (params) => get("/api/columns/", params).then(unwrap); +export const navColumns = (params) => get("/api/columns/nav/", params); +export const createColumn = (data) => post("/api/columns/", data); +export const updateColumn = (id, data) => patch(`/api/columns/${id}/`, data); +export const deleteColumn = (id) => remove(`/api/columns/${id}/`); + +export const listSubjects = (params) => + get("/api/subjects/", params).then(unwrap); +export const createSubject = (data) => post("/api/subjects/", data); +export const updateSubject = (id, data) => patch(`/api/subjects/${id}/`, data); +export const deleteSubject = (id) => remove(`/api/subjects/${id}/`); + +export const listArticleTypes = (params) => + get("/api/article-types/", params).then(unwrap); +export const createArticleType = (data) => post("/api/article-types/", data); +export const updateArticleType = (id, data) => + patch(`/api/article-types/${id}/`, data); +export const deleteArticleType = (id) => remove(`/api/article-types/${id}/`); + +export const listOrganizeCategories = (params) => + get("/api/organize-categories/", params).then(unwrap); +export const createOrganizeCategory = (data) => + post("/api/organize-categories/", data); +export const updateOrganizeCategory = (id, data) => + patch(`/api/organize-categories/${id}/`, data); +export const deleteOrganizeCategory = (id) => + remove(`/api/organize-categories/${id}/`); + +export const listMagazineCategories = (params) => + get("/api/magazine-categories/", params).then(unwrap); +export const createMagazineCategory = (data) => + post("/api/magazine-categories/", data); +export const updateMagazineCategory = (id, data) => + patch(`/api/magazine-categories/${id}/`, data); +export const deleteMagazineCategory = (id) => + remove(`/api/magazine-categories/${id}/`); +/** @deprecated 使用 listSubjects / listOrganizeCategories */ +export const listCategories = (params) => { + const type = params?.type; + if (type === 1) return listOrganizeCategories({ ...params, type: undefined }); + if (type === 2) return listSubjects({ ...params, type: undefined }); + if (type === 3) return listMagazineCategories({ ...params, type: undefined }); + return listSubjects(params); +}; + +export const listNews = (params) => get("/api/news/", params).then(unwrap); +export const getNews = (id) => get(`/api/news/${id}/`); +export const createNews = (data) => post("/api/news/", data); +export const updateNews = (id, data) => patch(`/api/news/${id}/`, data); +export const deleteNews = (id) => remove(`/api/news/${id}/`); + +export const listPublications = (params) => + get("/api/publications/", params).then(unwrap); +export const getPublication = (id) => get(`/api/publications/${id}/`); +export const createPublication = (data) => post("/api/publications/", data); +export const updatePublication = (id, data) => + patch(`/api/publications/${id}/`, data); +export const deletePublication = (id) => remove(`/api/publications/${id}/`); + +export const listReports = (params) => get("/api/reports/", params).then(unwrap); +export const getReport = (id) => get(`/api/reports/${id}/`); +export const createReport = (data) => post("/api/reports/", data); +export const updateReport = (id, data) => patch(`/api/reports/${id}/`, data); +export const deleteReport = (id) => remove(`/api/reports/${id}/`); + +export const getAbout = () => get("/api/about/current/"); +export const listAbout = (params) => get("/api/about/", params).then(unwrap); +export const createAbout = (data) => post("/api/about/", data); +export const updateAbout = (id, data) => patch(`/api/about/${id}/`, data); + +export const getCopyright = () => get("/api/copyright/current/"); +export const listCopyright = (params) => + get("/api/copyright/", params).then(unwrap); +export const createCopyright = (data) => post("/api/copyright/", data); +export const updateCopyright = (id, data) => patch(`/api/copyright/${id}/`, data); + +export const getContact = () => get("/api/contact/current/"); +export const listContact = (params) => get("/api/contact/", params).then(unwrap); +export const createContact = (data) => post("/api/contact/", data); +export const updateContact = (id, data) => patch(`/api/contact/${id}/`, data); + +export const listLinks = (params) => get("/api/links/", params).then(unwrap); +export const createLink = (data) => post("/api/links/", data); +export const updateLink = (id, data) => patch(`/api/links/${id}/`, data); +export const deleteLink = (id) => remove(`/api/links/${id}/`); + +export const listBanners = () => get("/api/banners/"); +export const createBanner = (data) => post("/api/banners/", data); +export const updateBanner = (id, data) => patch(`/api/banners/${id}/`, data); +export const deleteBanner = (id) => remove(`/api/banners/${id}/`); +export const uploadBannerImage = (formData) => + upload("/api/banners/upload/", formData); + +/** 通用媒体上传;formData 可含 file / category / kind */ +export const uploadMedia = (formData) => upload("/api/uploads/", formData); + +export const listKeywords = (params) => get("/api/keywords/", params).then(unwrap); +export const createKeyword = (data) => post("/api/keywords/", data); +export const updateKeyword = (id, data) => patch(`/api/keywords/${id}/`, data); +export const deleteKeyword = (id) => remove(`/api/keywords/${id}/`); + +export const listSpecialties = (params) => + get("/api/specialties/", params).then(unwrap); +export const createSpecialty = (data) => post("/api/specialties/", data); +export const updateSpecialty = (id, data) => patch(`/api/specialties/${id}/`, data); +export const deleteSpecialty = (id) => remove(`/api/specialties/${id}/`); + +export const listStarRules = (params) => + get("/api/star-rules/", params).then(unwrap); +export const getStarRule = (id) => get(`/api/star-rules/${id}/`); +export const createStarRule = (data) => post("/api/star-rules/", data); +export const updateStarRule = (id, data) => patch(`/api/star-rules/${id}/`, data); +export const deleteStarRule = (id) => remove(`/api/star-rules/${id}/`); + +export const listRuleBands = (params) => + get("/api/rule-bands/", params).then(unwrap); +export const createRuleBand = (data) => post("/api/rule-bands/", data); +export const updateRuleBand = (id, data) => patch(`/api/rule-bands/${id}/`, data); +export const deleteRuleBand = (id) => remove(`/api/rule-bands/${id}/`); + +export const listArticleComments = (params) => + get("/api/article-comments/", params).then(unwrap); +export const createArticleComment = (data) => post("/api/article-comments/", data); +export const updateArticleComment = (id, data) => + patch(`/api/article-comments/${id}/`, data); +export const deleteArticleComment = (id) => remove(`/api/article-comments/${id}/`); + +export const searchAll = (params) => get("/api/search/", params); diff --git a/src/services/index.js b/src/services/index.js index f62ee3f..0fc2d34 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1 +1,2 @@ export * as userApi from "./user"; +export * as contentApi from "./content"; diff --git a/src/services/user.js b/src/services/user.js index 47d5d8e..dca143a 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -1,232 +1,50 @@ -import { get, put, post, patch, remove, download } from "./utils"; +import { get, post, patch, remove } from "./utils"; -/** - * 登录 - * @param {object} parameters username, password - * @returns - */ -export const login = parameters => { +const unwrap = (data) => { + if (data && Array.isArray(data.results)) return data; + if (Array.isArray(data)) return { count: data.length, results: data }; + return data; +}; + +/** 后台登录 */ +export const login = (parameters) => { return post(`/api/auth/login/`, parameters); }; -/** - * 个人信息 - * @returns - */ -export const profile = parameters => { - return get(`/api/auth/info/`, parameters); -}; - -/** - * 通过邮箱查看个人信息 - * @returns - * */ -export const profileByEmail = parameters => { - return get(`/api/auth/info/email/`, parameters); -}; - -/** - * 通过邮箱加入工作组 - * @returns - */ -export const joinGroup = (memberId, parameters) => { - return post(`/api/auth/member/${memberId}/participate/`, parameters); -}; - -/** - * 注册 - * @param {*} parameters - * @returns - */ -export const register = parameters => { +/** 注册 staff(需已登录 staff) */ +export const register = (parameters) => { return post(`/api/auth/register/`, parameters); }; -/** - * 查询用户列表 - * @param {*} parameters - * @returns - */ -export const page = parameters => { - return get("/api/basis/user/", parameters); +/** 当前后台用户 */ +export const profile = () => { + return get(`/api/auth/me/`); }; -/** - * - * @param {*} parameters - * @returns - */ -export const list = parameters => { - return get("/api/auth/user-list/?no_page=true", parameters); +/** 前台会员登录 */ +export const memberLogin = (parameters) => { + return post(`/api/auth/member/login/`, parameters); }; -/** - * 给某个用户设置sec-admin角色 - * @param {string} user_id 用户ID - * @returns - */ -export const setAdminRole = user_id => { - return post(`/api/auth/group-set/`, { user_id }); +/** 前台会员注册 */ +export const memberRegister = (parameters) => { + return post(`/api/auth/member/register/`, parameters); }; -/** - * 给某个用户取消sec-admin角色 - * @param {string} user_id 用户ID - * @returns - */ -export const cancelAdminRole = user_id => { - return post(`/api/auth/group-cancel/`, { user_id }); +/** 当前前台会员 */ +export const memberProfile = () => { + return get(`/api/auth/member/me/`); }; -/** - * - * @param {*} id - * @returns - */ -export const detail = id => { - return get(`/api/basis/user/${id}/`); -}; +export const listStaff = (params) => + get("/api/auth/staff/", params).then(unwrap); +export const createStaff = (data) => post("/api/auth/staff/", data); +export const updateStaff = (id, data) => patch(`/api/auth/staff/${id}/`, data); +export const deleteStaff = (id) => remove(`/api/auth/staff/${id}/`); -/** - * - * @param {*} id - * @returns - */ -export const getUser = id => { - return get(`/api/basis/user/${id}/`); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const create = parameters => { - return post(`/api/basis/user/`, parameters); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const check = parameters => { - return get(`/api/user/username/check/`, parameters); -}; - -/** - * 用户名唯一检查 - * @param {*} parameters - * @returns - * */ -export const nameVerify = parameters => { - return post(`/api/auth/name-verify/`, parameters); -}; - -/** - * 邮箱唯一检查 - * @param {*} parameters - * @returns - * */ -export const emailVerify = parameters => { - return post(`/api/auth/email-verify/`, parameters); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const update = parameters => { - return post(`/api/auth/profile/`, parameters); -}; - -/** - * - * @param {*} id - * @param {*} parameters - * @returns - */ -export const partialUpdate = (id, parameters) => { - return patch(`/api/basis/user/${id}/`, parameters); -}; - -/** - *删除用户 - * @param {*} id - * @returns - */ -export const removeUser = id => { - return remove(`/api/auth/user-delete/${id}/`); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const updatePassword = parameters => { - return post(`/api/user/modify_pwd/`, parameters); -}; - -/** - * - * @param {*} id - * @returns - */ -export const resetPassword = id => { - return post(`/api/user/${id}/reset_pwd/`); -}; - -export const userResetPassword = parameters => { - return post(`/api/auth/password-reset/confirm/`, parameters); -}; - -export const editPwd = parameters => { - return post(`/api/auth/edit_pwd/`, parameters); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const exportCertificate = parameters => { - return get(`/api/user/export/`, parameters); -}; - -/** - * - * @param {*} parameters - * @returns - */ -export const createUserList = parameters => { - return get(`/api/user/list-user/`, parameters); -}; - -/** - * 查询用户在指南上的分组 - * @param {string} guide_id - * @returns - */ -export const getGroupsInGuide = guide_id => { - return get(`/api/group/${guide_id}/list/`); -}; - -/** - * 密码重置 - * @param {Object} parameters - * @returns - */ -export const passwordReset = parameters => { - return post(`/api/auth/password-reset/`, parameters); -}; - -/** - * 验证密码重置token - * @param {string} token - * @returns - */ -export const verifyPasswordResetToken = token => { - return get(`/api/auth/password-reset/verify/`, { token }); -}; +export const listMembers = (params) => + get("/api/auth/members/", params).then(unwrap); +export const createMember = (data) => post("/api/auth/members/", data); +export const updateMember = (id, data) => patch(`/api/auth/members/${id}/`, data); +export const deleteMember = (id) => remove(`/api/auth/members/${id}/`); +export const batchMemberVip = (data) => post("/api/auth/members/batch_vip/", data); diff --git a/src/services/utils.js b/src/services/utils.js index 9e5e73d..6336a1e 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -1,9 +1,24 @@ import axios from "axios"; import { store } from "@/store"; +/** 必须带前台会员 token 的接口(staff token 会被 IsMember 拒绝为 403) */ +function isMemberAuthApi(url = "") { + if (url.includes("/api/auth/member/")) return true; + if (url.includes("/api/articles/my-engagement")) return true; + if (url.includes("/api/articles/my-collections")) return true; + if (/\/api\/articles\/[^/]+\/toggle-(like|collect)\/?/.test(url)) return true; + // 分享去重依赖会员身份;有 member token 时优先带上 + if (/\/api\/articles\/[^/]+\/record-share\/?/.test(url)) return true; + return false; +} + axios.interceptors.request.use(config => { const state = store.getState(); - const token = state.user.token; + const url = config.url || ""; + // 会员专属接口优先 member token;其余后台接口优先 staff token + const token = isMemberAuthApi(url) + ? state.member?.token || state.user.token + : state.user.token || state.member?.token; if (config.headers && config.headers.Authorization !== "" && token) { config.headers.Authorization = "Token " + token; } @@ -40,11 +55,19 @@ const query = (method, endpoint, parameters, isUpload, isDownload) => { if (error.response) { const { status, data } = error.response; switch (status) { - case 401: - store.dispatch.user.logout(); + case 401: { + const reqUrl = error.config?.url || ""; + if (isMemberAuthApi(reqUrl)) { + store.dispatch.member.logout(); + } else if (store.getState().user.token) { + store.dispatch.user.logout(); + } else { + store.dispatch.member.logout(); + } return Promise.reject({ detail: "身份认证信息未提供,请重新登录!", }); + } default: return Promise.reject(data); } diff --git a/src/store.js b/src/store.js index 6ad1c76..192f1d3 100644 --- a/src/store.js +++ b/src/store.js @@ -8,7 +8,7 @@ import * as models from "./models"; const persistConfig = { key: "template-ui", storage, - whitelist: ["user"], + whitelist: ["user", "member"], }; export const store = init({ diff --git a/src/utils/index.js b/src/utils/index.js index 7cb303d..d4282a0 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,24 +2,37 @@ import { notification } from "antd"; export { formilyFileFormat } from "./formilyFIleFormat"; export { normalizeFileIds } from "./normalizeFileIds"; +/** 统一错误提示(登录、点赞收藏等用户操作失败时调用) */ export function feedback(err) { if (!err) return; if (err instanceof Error) { notification.error({ message: "错误", description: err.message }); - } else if (Object.prototype.toString.call(err) === "[object Object]") { - Object.keys(err).forEach(key => { + return; + } + if (Object.prototype.toString.call(err) === "[object Object]") { + // DRF 常见:{ detail: "..." } + if (err.detail != null && (typeof err.detail === "string" || typeof err.detail === "number")) { + notification.error({ message: "操作失败", description: String(err.detail) }); + return; + } + Object.keys(err).forEach((key) => { const value = err[key]; if (Array.isArray(value)) { - value.forEach(item => { - notification.error({ message: key, description: item }); + value.forEach((item) => { + notification.error({ message: key, description: String(item) }); + }); + } else if (value != null && typeof value === "object") { + notification.error({ + message: key, + description: JSON.stringify(value), }); } else { - notification.error({ message: key, description: value }); + notification.error({ message: key, description: String(value) }); } }); - } else { - notification.error({ message: "错误", description: err }); + return; } + notification.error({ message: "错误", description: String(err) }); } export const guideOptions = { @@ -588,8 +601,8 @@ export const COUNTRIES = [ { country_id: 100076, country_code: "+852", - country_name_en: "Hongkong", - country_name_cn: "香港", + country_name_en: "Hong Kong, China", + country_name_cn: "中国香港", ab: "HK", }, { @@ -791,8 +804,8 @@ export const COUNTRIES = [ { country_id: 100105, country_code: "+853", - country_name_en: "Macao", - country_name_cn: "澳门", + country_name_en: "Macao, China", + country_name_cn: "中国澳门", ab: "MO", }, { @@ -1274,8 +1287,8 @@ export const COUNTRIES = [ { country_id: 100174, country_code: "+886", - country_name_en: "Taiwan", - country_name_cn: "台湾省", + country_name_en: "Taiwan, China", + country_name_cn: "中国台湾", ab: "TW", }, { diff --git a/src/utils/navTree.js b/src/utils/navTree.js new file mode 100644 index 0000000..c403162 --- /dev/null +++ b/src/utils/navTree.js @@ -0,0 +1,58 @@ +import { TOP_NAV, FOOTER_NAV } from "@/config/nav"; + +/** 将栏目列表按 parent 组装成导航树 */ +export function buildColumnNavTree(columns, position) { + const list = (columns || []) + .filter((c) => c.position === position && !c.is_disabled) + .slice() + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); + + const childrenMap = new Map(); + list.forEach((c) => { + const pid = c.parent == null ? 0 : c.parent; + if (!childrenMap.has(pid)) childrenMap.set(pid, []); + childrenMap.get(pid).push(c); + }); + + const toNode = (c) => { + const kids = childrenMap.get(c.id) || []; + const node = { + id: c.id, + name: c.name, + to: c.path || "/index", + }; + if (kids.length) { + node.children = kids.map(toNode); + } + return node; + }; + + return (childrenMap.get(0) || []).map(toNode); +} + +/** API 无数据时,用静态配置 + i18n 兜底 */ +export function fallbackTopNav(t) { + return TOP_NAV.map((item) => ({ + id: item.key, + name: t(`nav.${item.key}`), + to: item.to, + children: item.children?.map((child) => ({ + id: child.key, + name: t(`nav.${child.key}`), + to: child.to, + })), + })); +} + +export function fallbackFooterNav(t) { + return FOOTER_NAV.map((group) => ({ + id: group.titleKey, + name: t(`nav.${group.titleKey}`), + to: group.links[0]?.to || "/index", + children: group.links.map((link) => ({ + id: link.key, + name: t(`nav.${link.key}`), + to: link.to, + })), + })); +} diff --git a/src/utils/phone.js b/src/utils/phone.js new file mode 100644 index 0000000..5b0448f --- /dev/null +++ b/src/utils/phone.js @@ -0,0 +1,25 @@ +/** + * 将区号与本地号码拼成国际格式,如 +8613800138000。 + * 本地号码若已带 +,则直接规范化后返回。 + */ +export function buildInternationalPhone(dialCode, localPhone) { + const raw = String(localPhone || "").trim().replace(/[\s()-]/g, ""); + if (!raw) return ""; + if (raw.startsWith("+")) { + return normalizeInternationalPhone(raw); + } + const code = String(dialCode || "").trim(); + const digits = raw.replace(/\D/g, ""); + if (!digits) return ""; + const prefix = code.startsWith("+") ? code : `+${code.replace(/\D/g, "")}`; + return `${prefix}${digits}`; +} + +/** 规范化为 + 后仅含数字 */ +export function normalizeInternationalPhone(phone) { + const raw = String(phone || "").trim().replace(/[\s()-]/g, ""); + if (!raw) return ""; + const withPlus = raw.startsWith("+") ? raw : `+${raw}`; + const digits = withPlus.slice(1).replace(/\D/g, ""); + return digits ? `+${digits}` : ""; +} diff --git a/src/utils/starLevel.js b/src/utils/starLevel.js new file mode 100644 index 0000000..349853e --- /dev/null +++ b/src/utils/starLevel.js @@ -0,0 +1,37 @@ +/** + * 按 STAR 规则分档,将得分映射为星级;优先匹配同年份规则。 + */ +export function resolveStarLevel(score, rules, year) { + if (score == null || score === "" || !rules?.length) return null; + const num = Number(score); + if (Number.isNaN(num)) return null; + + const active = (rules || []).filter((r) => !r.is_disabled); + const sorted = [...active].sort((a, b) => { + if (year != null) { + const aMatch = a.year === year ? 1 : 0; + const bMatch = b.year === year ? 1 : 0; + if (aMatch !== bMatch) return bMatch - aMatch; + } + return (b.year || 0) - (a.year || 0); + }); + + for (const rule of sorted) { + const bands = [...(rule.bands || [])] + .filter((b) => !b.is_disabled) + .sort((a, b) => Number(a.star_low) - Number(b.star_low)); + for (const band of bands) { + const low = Number(band.star_low); + const high = Number(band.star_high); + if (num >= low && num <= high) return Number(band.star); + } + } + return null; +} + +/** 将星级渲染为 ★☆ 文本(满 5 星) */ +export function formatStarGlyphs(level, max = 5) { + if (level == null || Number.isNaN(Number(level))) return ""; + const filled = Math.max(0, Math.min(max, Math.round(Number(level)))); + return `${"★".repeat(filled)}${"☆".repeat(Math.max(0, max - filled))}`; +} diff --git a/tailwind.config.js b/tailwind.config.js index 614c86b..0c2c4bf 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,7 +2,18 @@ export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { - extend: {}, + extend: { + colors: { + star: { + primary: "#181b62", + topbar: "#1d2b40", + bg: "#f6f8fc", + }, + }, + maxWidth: { + site: "1200px", + }, + }, }, plugins: [], }; diff --git a/vite.config.js b/vite.config.js index f4f30c9..8767aa3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,7 +17,10 @@ export default defineConfig({ "/api": { target: "http://127.0.0.1:8000", changeOrigin: true, - rewrite: (path) => path.replace(/\/api/, ""), + }, + "/media": { + target: "http://127.0.0.1:8000", + changeOrigin: true, }, }, },