feat: 增加项目无关的本地清洗评审器
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import eslint from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage", "node_modules"] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.flat.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>mdpolish 评审器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3578
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "mdpolish-reviewer-frontend",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src tests vite.config.ts",
|
||||
"test": "vitest run",
|
||||
"check": "npm run lint && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/merge": "6.12.2",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.1.0",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.4",
|
||||
"globals": "17.11.0",
|
||||
"jsdom": "30.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
mdpolish reviewer third-party notices
|
||||
|
||||
The bundled browser interface contains the following production dependencies.
|
||||
Each dependency's complete license text is included at the referenced path.
|
||||
|
||||
@codemirror/autocomplete 6.20.3 — MIT — licenses/codemirror__autocomplete.txt
|
||||
@codemirror/lang-css 6.3.1 — MIT — licenses/codemirror__lang-css.txt
|
||||
@codemirror/lang-html 6.4.12 — MIT — licenses/codemirror__lang-html.txt
|
||||
@codemirror/lang-javascript 6.2.5 — MIT — licenses/codemirror__lang-javascript.txt
|
||||
@codemirror/lang-markdown 6.5.2 — MIT — licenses/codemirror__lang-markdown.txt
|
||||
@codemirror/language 6.12.4 — MIT — licenses/codemirror__language.txt
|
||||
@codemirror/lint 6.9.7 — MIT — licenses/codemirror__lint.txt
|
||||
@codemirror/merge 6.12.2 — MIT — licenses/codemirror__merge.txt
|
||||
@codemirror/state 6.7.1 — MIT — licenses/codemirror__state.txt
|
||||
@codemirror/view 6.43.9 — MIT — licenses/codemirror__view.txt
|
||||
@lezer/common 1.5.2 — MIT — licenses/lezer__common.txt
|
||||
@lezer/css 1.3.6 — MIT — licenses/lezer__css.txt
|
||||
@lezer/highlight 1.2.3 — MIT — licenses/lezer__highlight.txt
|
||||
@lezer/html 1.3.13 — MIT — licenses/lezer__html.txt
|
||||
@lezer/javascript 1.5.4 — MIT — licenses/lezer__javascript.txt
|
||||
@lezer/lr 1.4.10 — MIT — licenses/lezer__lr.txt
|
||||
@lezer/markdown 1.7.2 — MIT — licenses/lezer__markdown.txt
|
||||
@marijn/find-cluster-break 1.0.4 — MIT — licenses/marijn__find-cluster-break.txt
|
||||
crelt 1.0.7 — MIT — licenses/crelt.txt
|
||||
style-mod 4.1.3 — MIT — licenses/style-mod.txt
|
||||
w3c-keyname 2.2.8 — MIT — licenses/w3c-keyname.txt
|
||||
react 19.2.8 — MIT — licenses/react.txt
|
||||
react-dom 19.2.8 — MIT — licenses/react-dom.txt
|
||||
scheduler 0.27.0 — MIT — licenses/scheduler.txt
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2022 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2024 by Marijn Haverbeke <marijn@haverbeke.berlin>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,381 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
ChangeDetail,
|
||||
CollectionSummaryResponse,
|
||||
DocumentComparisonResponse,
|
||||
ModifierStageResponse,
|
||||
RunStatus,
|
||||
} from "../shared/api.js";
|
||||
import { fetchCollection, fetchDocument, fetchModifierStage } from "./api-client.js";
|
||||
|
||||
const DiffView = lazy(async () => {
|
||||
const module = await import("./DiffView.js");
|
||||
return { default: module.DiffView };
|
||||
});
|
||||
|
||||
interface AsyncState<T> {
|
||||
loading: boolean;
|
||||
value: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
|
||||
|
||||
function statusLabel(status: RunStatus): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "unstable":
|
||||
return "不稳定";
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(hash: string): string {
|
||||
return `${hash.slice(0, 8)}…${hash.slice(-6)}`;
|
||||
}
|
||||
|
||||
function ErrorPanel({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="state-panel state-panel--error" role="alert">
|
||||
<span className="eyebrow">无法显示</span>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingPanel() {
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="loading-dot" />
|
||||
<p>正在校验本地评审数据…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeList({
|
||||
changes,
|
||||
onSelect,
|
||||
canJump,
|
||||
}: {
|
||||
changes: ChangeDetail[];
|
||||
onSelect: (change: ChangeDetail) => void;
|
||||
canJump: boolean;
|
||||
}) {
|
||||
if (changes.length === 0) {
|
||||
return <p className="quiet-message">这个 Modifier 运行过,但没有修改当前文档。</p>;
|
||||
}
|
||||
return (
|
||||
<ol className="change-list">
|
||||
{changes.map((change) => (
|
||||
<li
|
||||
key={`${change.modifier_position}-${change.proposal_index}-${change.edit_index}`}
|
||||
>
|
||||
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
|
||||
<span className="change-location">
|
||||
第 {change.location.line} 行,第 {change.location.column} 列 · 候选
|
||||
{change.proposal_index + 1} / 编辑 {change.edit_index + 1}
|
||||
</span>
|
||||
<strong>{change.reason}</strong>
|
||||
<span className="change-sample">
|
||||
<del>{change.before || "∅"}</del>
|
||||
<span aria-hidden="true">→</span>
|
||||
<ins>{change.after || "∅"}</ins>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [collectionState, setCollectionState] =
|
||||
useState<AsyncState<CollectionSummaryResponse>>(emptyState);
|
||||
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
|
||||
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
const [selectedModifier, setSelectedModifier] = useState<number | null>(null);
|
||||
const [stageState, setStageState] = useState<AsyncState<ModifierStageResponse>>({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchCollection(controller.signal)
|
||||
.then((collection) => {
|
||||
setCollectionState({ loading: false, value: collection, error: null });
|
||||
setSelectedDocument(collection.documents[0]?.document_id ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setCollectionState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取评审集合摘要。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModifier(null);
|
||||
setFocusRange(null);
|
||||
if (selectedDocument === null) {
|
||||
setDocumentState({ loading: false, value: null, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDocumentState(emptyState());
|
||||
fetchDocument(selectedDocument, controller.signal)
|
||||
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setDocumentState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取文档。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [selectedDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDocument === null || selectedModifier === null) {
|
||||
setStageState({ loading: false, value: null, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setStageState(emptyState());
|
||||
fetchModifierStage(selectedDocument, selectedModifier, controller.signal)
|
||||
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setStageState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取 Modifier 阶段。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [selectedDocument, selectedModifier]);
|
||||
|
||||
const visibleChanges = useMemo(() => {
|
||||
const document = documentState.value;
|
||||
if (document === null) {
|
||||
return [];
|
||||
}
|
||||
if (selectedModifier === null) {
|
||||
return document.changes;
|
||||
}
|
||||
return document.changes.filter((change) => change.modifier_position === selectedModifier);
|
||||
}, [documentState.value, selectedModifier]);
|
||||
|
||||
const selectChange = (change: ChangeDetail): void => {
|
||||
setSelectedModifier(change.modifier_position);
|
||||
setFocusRange(change.editor_range);
|
||||
};
|
||||
|
||||
if (collectionState.loading) {
|
||||
return <LoadingPanel />;
|
||||
}
|
||||
if (collectionState.error !== null || collectionState.value === null) {
|
||||
return <ErrorPanel message={collectionState.error ?? "评审集合摘要为空。"} />;
|
||||
}
|
||||
|
||||
const collection = collectionState.value;
|
||||
const document = documentState.value;
|
||||
const selectedSummary = collection.documents.find(
|
||||
(item) => item.document_id === selectedDocument,
|
||||
);
|
||||
const selectedStage = stageState.value;
|
||||
const canCompare =
|
||||
document?.document.status === "success" && document.current_markdown !== null;
|
||||
const beforeText = selectedStage?.before_markdown ?? document?.input_markdown ?? "";
|
||||
const afterText = selectedStage?.after_markdown ?? document?.current_markdown ?? "";
|
||||
const beforeLabel =
|
||||
selectedStage === null
|
||||
? "清洗前"
|
||||
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行前`;
|
||||
const afterLabel =
|
||||
selectedStage === null
|
||||
? "清洗后"
|
||||
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行后`;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<span className="brand-mark">md</span>
|
||||
<div>
|
||||
<p className="eyebrow">本地清洗评审器</p>
|
||||
<h1>{collection.collection.label}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="run-facts">
|
||||
<span className={`status status--${collection.collection.status}`}>
|
||||
{statusLabel(collection.collection.status)}
|
||||
</span>
|
||||
<span>{collection.summary.document_count} 份文档</span>
|
||||
<span>{collection.summary.change_count} 条修改</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="layout">
|
||||
<aside className="sidebar" aria-label="评审导航">
|
||||
<section>
|
||||
<div className="section-heading">
|
||||
<h2>文档</h2>
|
||||
<span>{collection.documents.length}</span>
|
||||
</div>
|
||||
<nav className="document-list" aria-label="文档列表">
|
||||
{collection.documents.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.document_id}
|
||||
className={item.document_id === selectedDocument ? "is-active" : ""}
|
||||
onClick={() => setSelectedDocument(item.document_id)}
|
||||
aria-current={item.document_id === selectedDocument ? "page" : undefined}
|
||||
>
|
||||
<span className={`status-dot status-dot--${item.status}`} />
|
||||
<span>
|
||||
<strong>{item.source_label}</strong>
|
||||
<small>{item.change_count} 条修改</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section className="modifier-section">
|
||||
<div className="section-heading">
|
||||
<h2>Modifier 时间线</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setSelectedModifier(null);
|
||||
setFocusRange(null);
|
||||
}}
|
||||
disabled={selectedModifier === null}
|
||||
>
|
||||
查看总结果
|
||||
</button>
|
||||
</div>
|
||||
<ol className="modifier-list">
|
||||
{(document?.modifiers ?? []).map((modifier) => (
|
||||
<li key={`${modifier.modifier_position}-${modifier.modifier_id}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={modifier.modifier_position === selectedModifier ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
setSelectedModifier(modifier.modifier_position);
|
||||
setFocusRange(null);
|
||||
}}
|
||||
disabled={!canCompare || !modifier.stage_available}
|
||||
>
|
||||
<span className="modifier-index">{modifier.modifier_position + 1}</span>
|
||||
<span>
|
||||
<strong>{modifier.modifier_id}</strong>
|
||||
<small>
|
||||
v{modifier.modifier_version} · {modifier.change_count} 条
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main className="workspace">
|
||||
<section className="document-header">
|
||||
<div>
|
||||
<p className="eyebrow">当前文档</p>
|
||||
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
|
||||
</div>
|
||||
{selectedSummary === undefined ? null : (
|
||||
<div className="document-meta">
|
||||
<span className={`status status--${selectedSummary.status}`}>
|
||||
{statusLabel(selectedSummary.status)}
|
||||
</span>
|
||||
<span title={selectedSummary.input_sha256}>
|
||||
输入 {shortHash(selectedSummary.input_sha256)}
|
||||
</span>
|
||||
<span title={selectedSummary.current_sha256}>
|
||||
当前 {shortHash(selectedSummary.current_sha256)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
|
||||
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
|
||||
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
|
||||
|
||||
{!documentState.loading && document !== null && document.document.status !== "success" ? (
|
||||
<div className="diagnostic-panel">
|
||||
<p className="eyebrow">没有正式清洗结果</p>
|
||||
<h3>{statusLabel(document.document.status)}文档只展示审计证据</h3>
|
||||
<p>partial output 不会在这里命名为清洗结果。</p>
|
||||
{document.errors.map((error) => (
|
||||
<article key={`${error.modifier_position}-${error.stage}-${error.code}`}>
|
||||
<strong>{error.diagnostic_type}</strong>
|
||||
<span>{error.message}</span>
|
||||
</article>
|
||||
))}
|
||||
{document.residual_proposals.map((proposal) => (
|
||||
<article key={`${proposal.modifier_position}-${proposal.proposal_index}`}>
|
||||
<strong>最终复查仍有 {proposal.edits.length} 项候选编辑</strong>
|
||||
<span>{proposal.reason}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
|
||||
<Suspense fallback={<LoadingPanel />}>
|
||||
<DiffView
|
||||
before={beforeText}
|
||||
after={afterText}
|
||||
beforeLabel={beforeLabel}
|
||||
afterLabel={afterLabel}
|
||||
focusRange={focusRange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{document !== null ? (
|
||||
<section className="changes-panel" aria-label="修改详情">
|
||||
<div className="changes-heading">
|
||||
<div>
|
||||
<p className="eyebrow">实际修改</p>
|
||||
<h3>
|
||||
{selectedModifier === null
|
||||
? `全部 Modifier · ${visibleChanges.length} 条`
|
||||
: `${document.modifiers[selectedModifier]?.modifier_id ?? "Modifier"} · ${
|
||||
visibleChanges.length
|
||||
} 条`}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedStage === null ? null : <p>{selectedStage.modifier.applicability}</p>}
|
||||
</div>
|
||||
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { MergeView } from "@codemirror/merge";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { drawSelection, EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface DiffViewProps {
|
||||
before: string;
|
||||
after: string;
|
||||
beforeLabel: string;
|
||||
afterLabel: string;
|
||||
focusRange?: { start: number; end: number } | null;
|
||||
}
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "#fbfaf7",
|
||||
color: "#262822",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: "1.68",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#f2f0ea",
|
||||
color: "#8a877e",
|
||||
border: "none",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "18px 0 36px",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 14px",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "2px solid #a7b9ac",
|
||||
outlineOffset: "-2px",
|
||||
},
|
||||
});
|
||||
|
||||
const readOnlyExtensions = [
|
||||
lineNumbers(),
|
||||
markdown(),
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
drawSelection(),
|
||||
EditorView.lineWrapping,
|
||||
editorTheme,
|
||||
];
|
||||
|
||||
export function DiffView({ before, after, beforeLabel, afterLabel, focusRange }: DiffViewProps) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const merge = useRef<MergeView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (host.current === null) {
|
||||
return undefined;
|
||||
}
|
||||
const view = new MergeView({
|
||||
parent: host.current,
|
||||
a: { doc: before, extensions: readOnlyExtensions },
|
||||
b: { doc: after, extensions: readOnlyExtensions },
|
||||
orientation: "a-b",
|
||||
gutter: true,
|
||||
highlightChanges: true,
|
||||
});
|
||||
merge.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
merge.current = null;
|
||||
};
|
||||
}, [before, after]);
|
||||
|
||||
// 必须在对应阶段文本重建完成后再次聚焦,不能只依赖 focusRange。
|
||||
useEffect(() => {
|
||||
const view = merge.current;
|
||||
if (view === null || focusRange === null || focusRange === undefined) {
|
||||
return;
|
||||
}
|
||||
const anchor = Math.min(Math.max(focusRange.start, 0), view.a.state.doc.length);
|
||||
const head = Math.min(Math.max(focusRange.end, anchor), view.a.state.doc.length);
|
||||
view.a.dispatch({
|
||||
selection: EditorSelection.range(anchor, head),
|
||||
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
|
||||
});
|
||||
view.a.focus();
|
||||
}, [focusRange, before, after]);
|
||||
|
||||
return (
|
||||
<section className="diff-shell" aria-label={`${beforeLabel}与${afterLabel}对比`}>
|
||||
<div className="diff-labels" aria-hidden="true">
|
||||
<span>{beforeLabel}</span>
|
||||
<span>{afterLabel}</span>
|
||||
</div>
|
||||
<div className="diff-host" ref={host} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ChangeDetail,
|
||||
CollectionSummaryResponse,
|
||||
DocumentComparisonResponse,
|
||||
DocumentSummary,
|
||||
ErrorDetail,
|
||||
ModifierStageResponse,
|
||||
ModifierSummary,
|
||||
ResidualProposalDetail,
|
||||
RunStatus,
|
||||
} from "../shared/api.js";
|
||||
|
||||
export class ReviewerApiError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ReviewerApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function invalid(label: string): never {
|
||||
throw new ReviewerApiError("invalid_response", `本地服务返回的 ${label} 格式不正确。`);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): JsonRecord {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string") {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : string(value, label);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum = 0): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(value: unknown, label: string): string {
|
||||
const digest = string(value, label);
|
||||
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function status(value: unknown): RunStatus {
|
||||
if (value !== "success" && value !== "failed" && value !== "unstable") {
|
||||
return invalid("status");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function documentSummary(value: unknown): DocumentSummary {
|
||||
const item = record(value, "document summary");
|
||||
const runStatus = status(item.status);
|
||||
const currentKind = item.current_kind;
|
||||
if (currentKind !== "success_output" && currentKind !== "partial_output") {
|
||||
return invalid("current_kind");
|
||||
}
|
||||
if ((runStatus === "success") !== (currentKind === "success_output")) {
|
||||
return invalid("document status/current_kind");
|
||||
}
|
||||
return {
|
||||
document_id: string(item.document_id, "document_id"),
|
||||
source_label: string(item.source_label, "source_label"),
|
||||
status: runStatus,
|
||||
current_kind: currentKind,
|
||||
input_sha256: hash(item.input_sha256, "input_sha256"),
|
||||
current_sha256: hash(item.current_sha256, "current_sha256"),
|
||||
modifier_count: integer(item.modifier_count, "modifier_count"),
|
||||
completed_stage_count: integer(item.completed_stage_count, "completed_stage_count"),
|
||||
change_count: integer(item.change_count, "change_count"),
|
||||
error_count: integer(item.error_count, "error_count"),
|
||||
residual_proposal_count: integer(item.residual_proposal_count, "residual_proposal_count"),
|
||||
};
|
||||
}
|
||||
|
||||
function modifier(value: unknown): ModifierSummary {
|
||||
const item = record(value, "modifier");
|
||||
return {
|
||||
modifier_position: integer(item.modifier_position, "modifier_position"),
|
||||
modifier_id: string(item.modifier_id, "modifier_id"),
|
||||
modifier_version: string(item.modifier_version, "modifier_version"),
|
||||
parameters: item.parameters,
|
||||
applicability: string(item.applicability, "applicability"),
|
||||
change_count: integer(item.change_count, "modifier change_count"),
|
||||
stage_available: boolean(item.stage_available, "stage_available"),
|
||||
};
|
||||
}
|
||||
|
||||
function range(value: unknown, label: string): { start: number; end: number } {
|
||||
const item = record(value, label);
|
||||
const start = integer(item.start, `${label}.start`);
|
||||
const end = integer(item.end, `${label}.end`);
|
||||
if (end < start) {
|
||||
return invalid(label);
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function change(value: unknown): ChangeDetail {
|
||||
const item = record(value, "change");
|
||||
const location = record(item.location, "change location");
|
||||
return {
|
||||
modifier_position: integer(item.modifier_position, "change modifier_position"),
|
||||
modifier_id: string(item.modifier_id, "change modifier_id"),
|
||||
modifier_version: string(item.modifier_version, "change modifier_version"),
|
||||
proposal_index: integer(item.proposal_index, "proposal_index"),
|
||||
edit_index: integer(item.edit_index, "edit_index"),
|
||||
reason: string(item.reason, "reason"),
|
||||
location: {
|
||||
line: integer(location.line, "location.line", 1),
|
||||
column: integer(location.column, "location.column", 1),
|
||||
},
|
||||
span: range(item.span, "span"),
|
||||
editor_range: range(item.editor_range, "editor_range"),
|
||||
before: string(item.before, "before"),
|
||||
after: string(item.after, "after"),
|
||||
before_sha256: hash(item.before_sha256, "before_sha256"),
|
||||
after_sha256: hash(item.after_sha256, "after_sha256"),
|
||||
};
|
||||
}
|
||||
|
||||
function runError(value: unknown): ErrorDetail {
|
||||
const item = record(value, "run error");
|
||||
return {
|
||||
code: string(item.code, "error code"),
|
||||
stage: string(item.stage, "error stage"),
|
||||
modifier_position: integer(item.modifier_position, "error modifier_position"),
|
||||
modifier_id: string(item.modifier_id, "error modifier_id"),
|
||||
modifier_version: string(item.modifier_version, "error modifier_version"),
|
||||
diagnostic_type: string(item.diagnostic_type, "diagnostic_type"),
|
||||
message: string(item.message, "error message"),
|
||||
};
|
||||
}
|
||||
|
||||
function residual(value: unknown): ResidualProposalDetail {
|
||||
const item = record(value, "residual proposal");
|
||||
return {
|
||||
modifier_position: integer(item.modifier_position, "residual modifier_position"),
|
||||
modifier_id: string(item.modifier_id, "residual modifier_id"),
|
||||
modifier_version: string(item.modifier_version, "residual modifier_version"),
|
||||
proposal_index: integer(item.proposal_index, "residual proposal_index"),
|
||||
snapshot_sha256: hash(item.snapshot_sha256, "residual snapshot_sha256"),
|
||||
reason: string(item.reason, "residual reason"),
|
||||
edits: array(item.edits, "residual edits"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCollection(value: unknown): CollectionSummaryResponse {
|
||||
const payload = record(value, "collection response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("collection schema_version");
|
||||
}
|
||||
const collection = record(payload.collection, "collection");
|
||||
const summary = record(payload.summary, "summary");
|
||||
return {
|
||||
schema_version: 1,
|
||||
collection: {
|
||||
label: string(collection.label, "collection label"),
|
||||
status: status(collection.status),
|
||||
},
|
||||
documents: array(payload.documents, "documents").map(documentSummary),
|
||||
summary: {
|
||||
document_count: integer(summary.document_count, "document_count"),
|
||||
success_count: integer(summary.success_count, "success_count"),
|
||||
failed_count: integer(summary.failed_count, "failed_count"),
|
||||
unstable_count: integer(summary.unstable_count, "unstable_count"),
|
||||
change_count: integer(summary.change_count, "change_count"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseDocument(value: unknown): DocumentComparisonResponse {
|
||||
const payload = record(value, "document response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("document schema_version");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
document: documentSummary(payload.document),
|
||||
modifiers: array(payload.modifiers, "modifiers").map(modifier),
|
||||
input_markdown: string(payload.input_markdown, "input_markdown"),
|
||||
current_markdown: nullableString(payload.current_markdown, "current_markdown"),
|
||||
changes: array(payload.changes, "changes").map(change),
|
||||
errors: array(payload.errors, "errors").map(runError),
|
||||
residual_proposals: array(payload.residual_proposals, "residual_proposals").map(residual),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStage(value: unknown): ModifierStageResponse {
|
||||
const payload = record(value, "modifier stage response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("modifier stage schema_version");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
document_id: string(payload.document_id, "document_id"),
|
||||
modifier: modifier(payload.modifier),
|
||||
before_sha256: hash(payload.before_sha256, "before_sha256"),
|
||||
after_sha256: hash(payload.after_sha256, "after_sha256"),
|
||||
before_markdown: string(payload.before_markdown, "before_markdown"),
|
||||
after_markdown: string(payload.after_markdown, "after_markdown"),
|
||||
changes: array(payload.changes, "changes").map(change),
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson<T>(
|
||||
pathname: string,
|
||||
parse: (payload: unknown) => T,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch(pathname, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as Partial<ApiErrorResponse>;
|
||||
throw new ReviewerApiError(
|
||||
errorPayload.error?.code ?? "request_failed",
|
||||
errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`,
|
||||
);
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
export function fetchCollection(signal?: AbortSignal): Promise<CollectionSummaryResponse> {
|
||||
return getJson("/api/v1/collection", parseCollection, signal);
|
||||
}
|
||||
|
||||
export function fetchDocument(
|
||||
documentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DocumentComparisonResponse> {
|
||||
return getJson(`/api/v1/documents/${encodeURIComponent(documentId)}`, parseDocument, signal);
|
||||
}
|
||||
|
||||
export function fetchModifierStage(
|
||||
documentId: string,
|
||||
modifierPosition: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ModifierStageResponse> {
|
||||
return getJson(
|
||||
`/api/v1/documents/${encodeURIComponent(documentId)}/modifiers/${modifierPosition}`,
|
||||
parseStage,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,539 @@
|
||||
:root {
|
||||
color: #252720;
|
||||
background: #ecebe5;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 1180px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #315f4b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgb(255 255 255 / 72%), transparent 34%),
|
||||
#ecebe5;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 76px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #d7d5cd;
|
||||
background: rgb(248 247 242 / 94%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.topbar > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: #284d3d;
|
||||
color: #f3f4ed;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: #78796f;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.run-facts,
|
||||
.document-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #66685f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.run-facts > span:not(.status),
|
||||
.document-meta > span:not(.status) {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #d5d2c9;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status--success {
|
||||
color: #277052;
|
||||
background: #edf6ef;
|
||||
}
|
||||
|
||||
.status--failed {
|
||||
color: #a04338;
|
||||
background: #fff0ed;
|
||||
}
|
||||
|
||||
.status--unstable {
|
||||
color: #986617;
|
||||
background: #fff7df;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
min-height: calc(100vh - 76px);
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 76px);
|
||||
border-right: 1px solid #d7d5cd;
|
||||
background: #f7f6f1;
|
||||
}
|
||||
|
||||
.sidebar section {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.sidebar section + section {
|
||||
border-top: 1px solid #dfddd5;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-heading > span {
|
||||
color: #888980;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.document-list,
|
||||
.modifier-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.document-list button,
|
||||
.modifier-list button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-list button {
|
||||
grid-template-columns: 9px 1fr;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.document-list button:hover,
|
||||
.modifier-list button:hover:not(:disabled) {
|
||||
background: #eceae2;
|
||||
}
|
||||
|
||||
.document-list button.is-active,
|
||||
.modifier-list button.is-active {
|
||||
background: #e0e8e0;
|
||||
color: #234b39;
|
||||
}
|
||||
|
||||
.document-list strong,
|
||||
.modifier-list strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-list small,
|
||||
.modifier-list small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #7d7e75;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
}
|
||||
|
||||
.status-dot--success {
|
||||
background: #348361;
|
||||
}
|
||||
|
||||
.status-dot--failed {
|
||||
background: #b64b3f;
|
||||
}
|
||||
|
||||
.status-dot--unstable {
|
||||
background: #bd831c;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #315f4b;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: #aaa99f;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modifier-list button {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.modifier-list button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.modifier-index {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 1px solid #d3d1c8;
|
||||
border-radius: 50%;
|
||||
color: #74766e;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 30px;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.document-header h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-shell,
|
||||
.changes-panel,
|
||||
.diagnostic-panel,
|
||||
.state-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid #d5d3ca;
|
||||
border-radius: 13px;
|
||||
background: #fbfaf7;
|
||||
box-shadow: 0 12px 36px rgb(55 57 48 / 7%);
|
||||
}
|
||||
|
||||
.diff-shell {
|
||||
min-height: 510px;
|
||||
}
|
||||
|
||||
.diff-labels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: 1px solid #dcdbd3;
|
||||
background: #f4f2ec;
|
||||
color: #6f7168;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diff-labels span {
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.diff-labels span + span {
|
||||
border-left: 1px solid #dcdbd3;
|
||||
}
|
||||
|
||||
.diff-host {
|
||||
height: 510px;
|
||||
}
|
||||
|
||||
.diff-host > .cm-mergeView {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.diff-host .cm-mergeViewEditors {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.diff-host .cm-editor {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changes-panel {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.changes-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid #e0ded6;
|
||||
}
|
||||
|
||||
.changes-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.changes-heading > p {
|
||||
max-width: 58%;
|
||||
margin: 0;
|
||||
color: #77786f;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.change-list {
|
||||
display: grid;
|
||||
max-height: 310px;
|
||||
gap: 1px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #e4e2da;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.change-list button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 145px minmax(240px, 1fr) minmax(260px, 0.9fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 0;
|
||||
padding: 11px 18px;
|
||||
background: #fbfaf7;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.change-list button:hover {
|
||||
background: #f4f5ef;
|
||||
}
|
||||
|
||||
.change-list button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.change-location {
|
||||
color: #73756c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.change-list strong {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.change-sample {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.change-sample del,
|
||||
.change-sample ins {
|
||||
overflow: hidden;
|
||||
max-width: 46%;
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.change-sample del {
|
||||
background: #f9ded9;
|
||||
color: #913e34;
|
||||
}
|
||||
|
||||
.change-sample ins {
|
||||
background: #dcecdf;
|
||||
color: #276348;
|
||||
}
|
||||
|
||||
.quiet-message {
|
||||
margin: 0;
|
||||
padding: 22px 18px;
|
||||
color: #77786f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.state-panel,
|
||||
.diagnostic-panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
color: #66685f;
|
||||
}
|
||||
|
||||
.state-panel p {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.state-panel--error {
|
||||
border-color: #e2b6ae;
|
||||
color: #8f3e34;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: #3d735b;
|
||||
box-shadow: 0 0 0 7px #dce9df;
|
||||
animation: pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.diagnostic-panel h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.diagnostic-panel > p:not(.eyebrow) {
|
||||
color: #686a61;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.diagnostic-panel article {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #e0ded6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.layout {
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.change-list button {
|
||||
grid-template-columns: 125px minmax(180px, 1fr) minmax(220px, 0.8fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type RunStatus = "success" | "failed" | "unstable";
|
||||
|
||||
export interface ModifierSummary {
|
||||
modifier_position: number;
|
||||
modifier_id: string;
|
||||
modifier_version: string;
|
||||
parameters: unknown;
|
||||
applicability: string;
|
||||
change_count: number;
|
||||
stage_available: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
document_id: string;
|
||||
source_label: string;
|
||||
status: RunStatus;
|
||||
current_kind: "success_output" | "partial_output";
|
||||
input_sha256: string;
|
||||
current_sha256: string;
|
||||
modifier_count: number;
|
||||
completed_stage_count: number;
|
||||
change_count: number;
|
||||
error_count: number;
|
||||
residual_proposal_count: number;
|
||||
}
|
||||
|
||||
export interface CollectionSummaryResponse {
|
||||
schema_version: 1;
|
||||
collection: {
|
||||
label: string;
|
||||
status: RunStatus;
|
||||
};
|
||||
documents: DocumentSummary[];
|
||||
summary: {
|
||||
document_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
unstable_count: number;
|
||||
change_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChangeDetail {
|
||||
modifier_position: number;
|
||||
modifier_id: string;
|
||||
modifier_version: string;
|
||||
proposal_index: number;
|
||||
edit_index: number;
|
||||
reason: string;
|
||||
location: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
span: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
editor_range: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
before: string;
|
||||
after: string;
|
||||
before_sha256: string;
|
||||
after_sha256: string;
|
||||
}
|
||||
|
||||
export interface ErrorDetail {
|
||||
code: string;
|
||||
stage: string;
|
||||
modifier_position: number;
|
||||
modifier_id: string;
|
||||
modifier_version: string;
|
||||
diagnostic_type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ResidualProposalDetail {
|
||||
modifier_position: number;
|
||||
modifier_id: string;
|
||||
modifier_version: string;
|
||||
proposal_index: number;
|
||||
snapshot_sha256: string;
|
||||
reason: string;
|
||||
edits: unknown[];
|
||||
}
|
||||
|
||||
export interface DocumentComparisonResponse {
|
||||
schema_version: 1;
|
||||
document: DocumentSummary;
|
||||
modifiers: ModifierSummary[];
|
||||
input_markdown: string;
|
||||
current_markdown: string | null;
|
||||
changes: ChangeDetail[];
|
||||
errors: ErrorDetail[];
|
||||
residual_proposals: ResidualProposalDetail[];
|
||||
}
|
||||
|
||||
export interface ModifierStageResponse {
|
||||
schema_version: 1;
|
||||
document_id: string;
|
||||
modifier: ModifierSummary;
|
||||
before_sha256: string;
|
||||
after_sha256: string;
|
||||
before_markdown: string;
|
||||
after_markdown: string;
|
||||
changes: ChangeDetail[];
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { App } from "../src/client/App.js";
|
||||
import type {
|
||||
CollectionSummaryResponse,
|
||||
DocumentComparisonResponse,
|
||||
ModifierStageResponse,
|
||||
} from "../src/shared/api.js";
|
||||
|
||||
vi.mock("../src/client/DiffView.js", () => ({
|
||||
DiffView: ({ beforeLabel, afterLabel }: { beforeLabel: string; afterLabel: string }) => (
|
||||
<div data-testid="diff-view">
|
||||
{beforeLabel} / {afterLabel}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const modifiers = [
|
||||
{
|
||||
modifier_position: 0,
|
||||
modifier_id: "paper.rule",
|
||||
modifier_version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "替换测试单词。",
|
||||
change_count: 1,
|
||||
stage_available: true,
|
||||
},
|
||||
{
|
||||
modifier_position: 1,
|
||||
modifier_id: "paper.zero",
|
||||
modifier_version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "不修改当前测试文档。",
|
||||
change_count: 0,
|
||||
stage_available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const documentSummary = {
|
||||
document_id: "paper",
|
||||
source_label: "paper.md",
|
||||
status: "success" as const,
|
||||
current_kind: "success_output" as const,
|
||||
input_sha256: "1".repeat(64),
|
||||
current_sha256: "2".repeat(64),
|
||||
modifier_count: 2,
|
||||
completed_stage_count: 2,
|
||||
change_count: 1,
|
||||
error_count: 0,
|
||||
residual_proposal_count: 0,
|
||||
};
|
||||
|
||||
const collectionResponse: CollectionSummaryResponse = {
|
||||
schema_version: 1,
|
||||
collection: { label: "合成评审", status: "success" },
|
||||
documents: [documentSummary],
|
||||
summary: {
|
||||
document_count: 1,
|
||||
success_count: 1,
|
||||
failed_count: 0,
|
||||
unstable_count: 0,
|
||||
change_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const change = {
|
||||
modifier_position: 0,
|
||||
modifier_id: "paper.rule",
|
||||
modifier_version: "1.0.0",
|
||||
proposal_index: 0,
|
||||
edit_index: 0,
|
||||
reason: "替换测试单词",
|
||||
location: { line: 1, column: 1 },
|
||||
span: { start: 0, end: 3 },
|
||||
editor_range: { start: 0, end: 3 },
|
||||
before: "old",
|
||||
after: "new",
|
||||
before_sha256: "1".repeat(64),
|
||||
after_sha256: "2".repeat(64),
|
||||
};
|
||||
|
||||
const documentResponse: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: documentSummary,
|
||||
modifiers,
|
||||
input_markdown: "old",
|
||||
current_markdown: "new",
|
||||
changes: [change],
|
||||
errors: [],
|
||||
residual_proposals: [],
|
||||
};
|
||||
|
||||
const stageResponse: ModifierStageResponse = {
|
||||
schema_version: 1,
|
||||
document_id: "paper",
|
||||
modifier: modifiers[0]!,
|
||||
before_sha256: "1".repeat(64),
|
||||
after_sha256: "2".repeat(64),
|
||||
before_markdown: "old",
|
||||
after_markdown: "new",
|
||||
changes: [change],
|
||||
};
|
||||
|
||||
function response(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows documents, Modifier order and the selected stage", async () => {
|
||||
const fetchMock = vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (pathname === "/api/v1/collection") {
|
||||
return Promise.resolve(response(collectionResponse));
|
||||
}
|
||||
if (pathname.endsWith("/modifiers/0")) {
|
||||
return Promise.resolve(response(stageResponse));
|
||||
}
|
||||
return Promise.resolve(response(documentResponse));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "合成评审" })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
|
||||
expect(screen.getByText("paper.rule")).toBeInTheDocument();
|
||||
expect(screen.getByText("paper.zero")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("modifiers/0"), expect.anything());
|
||||
});
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
|
||||
"Modifier 1 执行前 / Modifier 1 执行后",
|
||||
);
|
||||
});
|
||||
|
||||
it("clicks a change through its Modifier stage", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (pathname === "/api/v1/collection") {
|
||||
return Promise.resolve(response(collectionResponse));
|
||||
}
|
||||
if (pathname.endsWith("/modifiers/0")) {
|
||||
return Promise.resolve(response(stageResponse));
|
||||
}
|
||||
return Promise.resolve(response(documentResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /替换测试单词/ }));
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
|
||||
"Modifier 1 执行前 / Modifier 1 执行后",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows failed diagnostics without a cleaned comparison", async () => {
|
||||
const failedSummary = {
|
||||
...documentSummary,
|
||||
status: "failed" as const,
|
||||
current_kind: "partial_output" as const,
|
||||
current_sha256: documentSummary.input_sha256,
|
||||
completed_stage_count: 0,
|
||||
change_count: 0,
|
||||
error_count: 1,
|
||||
};
|
||||
const failedCollection: CollectionSummaryResponse = {
|
||||
...collectionResponse,
|
||||
collection: { ...collectionResponse.collection, status: "failed" },
|
||||
documents: [failedSummary],
|
||||
summary: {
|
||||
...collectionResponse.summary,
|
||||
success_count: 0,
|
||||
failed_count: 1,
|
||||
change_count: 0,
|
||||
},
|
||||
};
|
||||
const failedDocument: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: failedSummary,
|
||||
modifiers: modifiers.map((modifier) => ({
|
||||
...modifier,
|
||||
change_count: 0,
|
||||
stage_available: false,
|
||||
})),
|
||||
input_markdown: "原文",
|
||||
current_markdown: null,
|
||||
changes: [],
|
||||
errors: [
|
||||
{
|
||||
code: "run.transform_failed",
|
||||
stage: "transform",
|
||||
modifier_position: 0,
|
||||
modifier_id: "paper.rule",
|
||||
modifier_version: "1.0.0",
|
||||
diagnostic_type: "SyntheticError",
|
||||
message: "测试 Modifier 失败。",
|
||||
},
|
||||
],
|
||||
residual_proposals: [],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return Promise.resolve(
|
||||
response(
|
||||
pathname === "/api/v1/collection" ? failedCollection : failedDocument,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
|
||||
expect(screen.getByText("测试 Modifier 失败。")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DiffView } from "../src/client/DiffView.js";
|
||||
|
||||
function leftPaneSelection(): { from: number; to: number } | null {
|
||||
const pane = document.querySelector(".diff-host .cm-editor");
|
||||
if (pane === null) {
|
||||
return null;
|
||||
}
|
||||
const view = EditorView.findFromDOM(pane as HTMLElement);
|
||||
if (view === null) {
|
||||
return null;
|
||||
}
|
||||
const { from, to } = view.state.selection.main;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
describe("DiffView", () => {
|
||||
it("keeps Markdown and raw HTML as inert editor text", () => {
|
||||
render(
|
||||
<DiffView
|
||||
before={'# title\n<img src="https://example.com/private.png" onerror="alert(1)">'}
|
||||
after={'# title\n<script>alert("x")</script>'}
|
||||
beforeLabel="清洗前"
|
||||
afterLabel="清洗后"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("region", { name: "清洗前与清洗后对比" })).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps long unchanged sections available in the full document view", () => {
|
||||
const before = Array.from({ length: 30 }, (_, index) => `line ${index + 1}`);
|
||||
const after = [...before];
|
||||
after[14] = "changed line 15";
|
||||
|
||||
render(
|
||||
<DiffView
|
||||
before={before.join("\n")}
|
||||
after={after.join("\n")}
|
||||
beforeLabel="清洗前"
|
||||
afterLabel="清洗后"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector(".cm-collapsedLines")).toBeNull();
|
||||
expect(document.querySelector(".cm-mergeView")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-applies the focus selection after the compared texts change", () => {
|
||||
const longText = (mark: string) =>
|
||||
Array.from({ length: 30 }, (_, index) => (index === 14 ? mark : `line ${index + 1}`)).join("\n");
|
||||
|
||||
const { rerender } = render(
|
||||
<DiffView before={longText("old")} after={longText("new")} beforeLabel="清洗前" afterLabel="清洗后" />,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<DiffView
|
||||
before={longText("old")}
|
||||
after={longText("new")}
|
||||
beforeLabel="Modifier 1 执行前"
|
||||
afterLabel="Modifier 1 执行后"
|
||||
focusRange={{ start: 58, end: 61 }}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<DiffView
|
||||
before={longText("stage before")}
|
||||
after={longText("stage after")}
|
||||
beforeLabel="Modifier 2 执行前"
|
||||
afterLabel="Modifier 2 执行后"
|
||||
focusRange={{ start: 58, end: 71 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Modifier 2 执行前与Modifier 2 执行后对比" }),
|
||||
).toBeInTheDocument();
|
||||
expect(leftPaneSelection()).toEqual({ from: 58, to: 71 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchCollection } from "../src/client/api-client.js";
|
||||
|
||||
describe("API response validation", () => {
|
||||
it("rejects a successful HTTP response with an unknown schema", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ schema_version: 2 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchCollection()).rejects.toMatchObject({ code: "invalid_response" });
|
||||
});
|
||||
|
||||
it("uses the server error instead of guessing a partial schema", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "unsupported_review_schema",
|
||||
message: "reviewer 与 JSON schema 不匹配。",
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchCollection()).rejects.toMatchObject({
|
||||
code: "unsupported_review_schema",
|
||||
message: "reviewer 与 JSON schema 不匹配。",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:4174",
|
||||
changeOrigin: true,
|
||||
configure(proxy) {
|
||||
proxy.on("proxyReq", (request) => request.removeHeader("origin"));
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../src/mdpolish/_reviewer_static",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./tests/setup.ts",
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user