优化文档相关

This commit is contained in:
2026-09-10 12:57:16 +08:00
parent 8a0334a020
commit e340d9a7d1
17 changed files with 3131 additions and 457 deletions
+513 -10
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@turbodocx/html-to-docx": "^1.22.2",
"@umoteam/editor": "^10.2.1",
"axios": "^1.13.1",
"chart": "^0.1.2",
@@ -679,7 +680,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -701,12 +701,21 @@
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -717,7 +726,6 @@
"version": "0.3.31",
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1024,6 +1032,102 @@
"node": ">= 8"
}
},
"node_modules/@oozcitak/dom": {
"version": "1.15.5",
"resolved": "https://registry.npmmirror.com/@oozcitak/dom/-/dom-1.15.5.tgz",
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@oozcitak/infra/-/infra-1.0.5.tgz",
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@oozcitak/url/-/url-1.0.0.tgz",
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.3",
"@oozcitak/util": "1.0.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@oozcitak/infra/-/infra-1.0.3.tgz",
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "1.0.1"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@oozcitak/util/-/util-1.0.1.tgz",
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@oozcitak/util/-/util-1.0.2.tgz",
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.3",
"resolved": "https://registry.npmmirror.com/@oozcitak/util/-/util-8.3.3.tgz",
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
@@ -2009,6 +2113,30 @@
"node": ">=18.0.0"
}
},
"node_modules/@turbodocx/html-to-docx": {
"version": "1.22.2",
"resolved": "https://registry.npmmirror.com/@turbodocx/html-to-docx/-/html-to-docx-1.22.2.tgz",
"integrity": "sha512-OmwsD6TMVL8Z4EDLiashTcwfaibTs3ot3CdbdCV2Wt+BsQMN73XEaYfzuQZqt2iX8cnXqD6R+9/2uMwjiQBvvg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"axios": "^1.12.2",
"color-name": "^1.1.4",
"html-entities": "^2.3.3",
"html-minifier-terser": "^7.2.0",
"htmlparser2": "^10.0.0",
"jszip": "^3.7.1",
"lodash": "^4.17.21",
"lru-cache": "^10.4.3",
"mime-types": "^2.1.35",
"nanoid": "^3.3.18",
"probe-image-size": "^7.2.3",
"xmlbuilder2": "2.1.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz",
@@ -3160,7 +3288,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -3304,6 +3431,12 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
@@ -3351,6 +3484,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camel-case": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/camel-case/-/camel-case-4.1.2.tgz",
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
"license": "MIT",
"dependencies": {
"pascal-case": "^3.1.2",
"tslib": "^2.0.3"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
@@ -3422,6 +3565,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clean-css": {
"version": "5.3.3",
"resolved": "https://registry.npmmirror.com/clean-css/-/clean-css-5.3.3.tgz",
"integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
"license": "MIT",
"dependencies": {
"source-map": "~0.6.0"
},
"engines": {
"node": ">= 10.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
@@ -3439,6 +3594,12 @@
"node": "*"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4216,12 +4377,65 @@
"jszip": ">=3.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/dom-to-image-more": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.7.2.tgz",
"integrity": "sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==",
"license": "MIT"
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz",
@@ -4231,6 +4445,30 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dot-case": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/dot-case/-/dot-case-3.0.4.tgz",
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4972,6 +5210,64 @@
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-minifier-terser": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
"integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",
"license": "MIT",
"dependencies": {
"camel-case": "^4.1.2",
"clean-css": "~5.3.2",
"commander": "^10.0.0",
"entities": "^4.4.0",
"param-case": "^3.0.4",
"relateurl": "^0.2.7",
"terser": "^5.15.1"
},
"bin": {
"html-minifier-terser": "cli.js"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
}
},
"node_modules/html-minifier-terser/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/html-minifier-terser/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -4985,6 +5281,25 @@
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5645,6 +5960,21 @@
"lodash-es": "*"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz",
@@ -5660,6 +5990,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -5932,13 +6268,12 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -5970,6 +6305,16 @@
"node": ">= 4.4.x"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"license": "MIT",
"dependencies": {
"lower-case": "^2.0.2",
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -6093,6 +6438,16 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/param-case/-/param-case-3.0.4.tgz",
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
"license": "MIT",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parse-node-version/-/parse-node-version-1.0.1.tgz",
@@ -6102,6 +6457,16 @@
"node": ">= 0.10"
}
},
"node_modules/pascal-case": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/pascal-case/-/pascal-case-3.1.2.tgz",
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz",
@@ -6280,6 +6645,65 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/probe-image-size": {
"version": "7.4.0",
"resolved": "https://registry.npmmirror.com/probe-image-size/-/probe-image-size-7.4.0.tgz",
"integrity": "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"lodash.merge": "^4.6.2",
"needle": "^2.5.2",
"stream-parser": "~0.3.1"
}
},
"node_modules/probe-image-size/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/probe-image-size/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/probe-image-size/node_modules/needle": {
"version": "2.9.1",
"resolved": "https://registry.npmmirror.com/needle/-/needle-2.9.1.tgz",
"integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.6",
"iconv-lite": "^0.4.4",
"sax": "^1.2.4"
},
"bin": {
"needle": "bin/needle"
},
"engines": {
"node": ">= 4.4.x"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -6651,6 +7075,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/relateurl": {
"version": "0.2.7",
"resolved": "https://registry.npmmirror.com/relateurl/-/relateurl-0.2.7.tgz",
"integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
@@ -6856,7 +7289,6 @@
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=11.0.0"
}
@@ -7013,7 +7445,6 @@
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7027,6 +7458,16 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
@@ -7071,6 +7512,30 @@
"node": ">= 0.4"
}
},
"node_modules/stream-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/stream-parser/-/stream-parser-0.3.1.tgz",
"integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==",
"license": "MIT",
"dependencies": {
"debug": "2"
}
},
"node_modules/stream-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/stream-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -7204,6 +7669,30 @@
"node": ">=12.0.0"
}
},
"node_modules/terser": {
"version": "5.51.2",
"resolved": "https://registry.npmmirror.com/terser/-/terser-5.51.2.tgz",
"integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/tesseract.js/-/tesseract.js-7.0.0.tgz",
@@ -7970,6 +8459,20 @@
"node": ">=0.8"
}
},
"node_modules/xmlbuilder2": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.5",
"@oozcitak/infra": "1.0.5",
"@oozcitak/util": "8.3.3"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/y-prosemirror": {
"version": "1.2.15",
"resolved": "https://registry.npmmirror.com/y-prosemirror/-/y-prosemirror-1.2.15.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@turbodocx/html-to-docx": "^1.22.2",
"@umoteam/editor": "^10.2.1",
"axios": "^1.13.1",
"chart": "^0.1.2",
+16 -5
View File
@@ -5,11 +5,12 @@ import request from "@/utils/request";
// ---------------- 分类 ----------------
// 分类树(每个节点带 doc_count)
export function getDocCategories() {
// 分类树(每个节点带 doc_count),全租户共用一套,不再区分个人/共享
export function getDocCategories(params) {
return request({
url: "/backend/oa/document/categories",
method: "get"
method: "get",
params
});
}
@@ -46,10 +47,12 @@ export function getDocList(params) {
});
}
export function getDocStats() {
// 概览统计:可见性维度(不再区分个人/共享)
export function getDocStats(params) {
return request({
url: "/backend/oa/document/stats",
method: "get"
method: "get",
params
});
}
@@ -61,6 +64,14 @@ export function getDocDetail(id) {
});
}
// 在线文档编辑源(不累加查看次数),用于打开在线编辑器回填内容
export function getDocContent(id) {
return request({
url: `/backend/oa/document/content/${id}`,
method: "get"
});
}
export function createDoc(data) {
return request({
url: "/backend/oa/document/create",
+5
View File
@@ -18,6 +18,11 @@ import { useAuthStore } from './stores/auth'
// 导入全局组件
import UmoEditor from '@/views/components/UmoEditor.vue';
// 兼容某些依赖(如 HTML→docx 转换库)对 Node 全局变量 global 的引用
if (typeof window !== 'undefined' && !window.global) {
window.global = window;
}
const app = createApp(App)
const pinia = createPinia()
// 全局注册 UmoEditor 组件
@@ -3,9 +3,12 @@
<div class="page-header">
<div>
<h2>文档图谱</h2>
<p>以知识图谱呈现文档之间的引用、相关与归属关系,点击节点查看文档详情</p>
<p>
默认展示顶级往下 3 级分类脉络,点击带 ▸ 的分类节点可继续延展 3 级;滚轮缩放,按住空格 + 左键拖拽平移
</p>
</div>
<div class="header-actions">
<el-button :icon="RefreshLeft" @click="collapseAll">收起全部</el-button>
<el-button :icon="Refresh" @click="loadGraph">刷新</el-button>
<el-button type="primary" :icon="Aim" @click="fitView">重新布局</el-button>
</div>
@@ -25,12 +28,13 @@
<el-tree-select
v-model="filters.category_id"
:data="categories"
:props="{ label: 'name', children: 'children' }"
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
node-key="id"
check-strictly
clearable
default-expand-all
placeholder="全部分类"
style="width: 180px"
style="width: 200px"
/>
<el-select
v-model="filters.relation"
@@ -50,7 +54,12 @@
<el-option label="最多 300 个" :value="300" />
<el-option label="最多 600 个" :value="600" />
</el-select>
<el-select v-model="filters.layout" style="width: 130px">
<el-option label="力导向布局" value="force" />
<el-option label="环形布局" value="circular" />
</el-select>
<el-checkbox v-model="filters.includeCategory">显示分类中心</el-checkbox>
<el-checkbox v-model="filters.showDocs">显示文档</el-checkbox>
<el-checkbox v-model="filters.onlyLinked">仅看有关系的文档</el-checkbox>
<el-button type="primary" :icon="Search" @click="loadGraph">生成图谱</el-button>
<el-button :icon="RefreshLeft" @click="resetFilters">重置</el-button>
@@ -59,8 +68,8 @@
<div v-loading="loading" class="chart-wrap">
<div ref="chartRef" class="chart" />
<el-empty
v-if="!loading && !nodes.length"
description="暂无可展示的文档,请先到文档库上传文档"
v-if="!loading && !displayNodes.length"
description="暂无可展示的分类或文档,请先到文档库新建分类并上传文档"
class="chart-empty"
/>
</div>
@@ -70,7 +79,13 @@
<i class="dot" style="background: #409eff" />文档
</span>
<span class="legend-item">
<i class="dot" style="background: #e6a23c" />分类
<i class="dot" style="background: #4e7cf6" />1级分类
</span>
<span class="legend-item">
<i class="dot" style="background: #2bb673" />2级分类
</span>
<span class="legend-item">
<i class="dot" style="background: #e6a23c" />3级分类
</span>
<span class="legend-item">
<i class="line solid" />关联关系
@@ -78,7 +93,12 @@
<span class="legend-item">
<i class="line dashed" />归属分类
</span>
<span class="legend-tip">滚轮缩放 · 拖拽平移 · 点击节点查看详情</span>
<span class="legend-item">
<i class="line dotted" />分类层级
</span>
<span class="legend-tip"
>滚轮缩放 · 空格+左键拖拽平移 · 点击 ▸ 分类展开下级(更深层级循环使用这 3 色)</span
>
</div>
</div>
@@ -153,7 +173,15 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch
} from "vue";
import { ElMessage } from "element-plus";
import { Aim, Refresh, RefreshLeft, Search } from "@element-plus/icons-vue";
import * as echarts from "echarts";
@@ -167,9 +195,11 @@ let resizeObserver = null;
let themeObserver = null;
const loading = ref(false);
const categories = ref([]);
const nodes = ref([]);
const links = ref([]);
const categories = ref([]); // 分组后的分类(用于筛选下拉)
const sharedTree = ref([]); // 共享分类树(用于分层展开)
const personalTree = ref([]); // 私密分类树(用于分层展开)
const nodes = ref([]); // 原始节点
const links = ref([]); // 原始连线
const stats = ref({});
const relationCounts = ref([]);
const topNodes = ref([]);
@@ -177,15 +207,125 @@ const topNodes = ref([]);
const detailVisible = ref(false);
const detailId = ref(0);
// 分层展开:默认展示顶级往下 3 级,点击分支节点后在该分支继续延展 3 级
const INITIAL_DEPTH = 3;
const expandedIds = ref(new Set());
// 按住空格 + 鼠标左键拖拽平移
const spaceHeld = ref(false);
const filters = reactive({
keyword: "",
category_id: null,
relation: "",
limit: 300,
layout: "force",
includeCategory: true,
onlyLinked: false
onlyLinked: false,
showDocs: true
});
// ---------------- 分类层级索引 & 分层可见集合 ----------------
const catIndex = computed(() => {
const byId = new Map();
const childrenOf = new Map();
const parentOf = new Map();
const depthOf = new Map(); // 分类层级:顶级=1
const roots = [];
const walk = (list, parentId, depth) => {
(list || []).forEach(n => {
byId.set(n.id, n);
depthOf.set(n.id, depth);
if (parentId) parentOf.set(n.id, parentId);
else roots.push(n.id);
const kids = n.children || [];
if (kids.length) {
childrenOf.set(
n.id,
kids.map(c => c.id)
);
walk(kids, n.id, depth + 1);
}
});
};
walk(sharedTree.value, 0, 1);
walk(personalTree.value, 0, 1);
return { byId, childrenOf, parentOf, depthOf, roots };
});
// 分类层级配色:只保留 3 种颜色(对应同时展示的 3 级),更深层级按 1/2/3 级循环
const CATEGORY_LEVEL_COLORS = ["#4E7CF6", "#2BB673", "#E6A23C"];
function categoryColor(depth) {
const d = Math.max(1, Number(depth) || 1);
return CATEGORY_LEVEL_COLORS[(d - 1) % CATEGORY_LEVEL_COLORS.length];
}
const visibleCatIds = computed(() => {
const { childrenOf, roots } = catIndex.value;
const expanded = expandedIds.value;
const visible = new Set();
const walk = (id, budget) => {
if (budget <= 0) return;
visible.add(id);
for (const kid of childrenOf.get(id) || []) {
// 已展开的分支:从该节点重新给 INITIAL_DEPTH 级预算;否则继续消耗剩余预算
walk(kid, expanded.has(id) ? INITIAL_DEPTH : budget - 1);
}
};
roots.forEach(r => walk(r, INITIAL_DEPTH));
return visible;
});
// 每个可见分类隐藏的直接子级数量(用于 +N 提示可展开)
const hiddenChildCounts = computed(() => {
const { childrenOf } = catIndex.value;
const visible = visibleCatIds.value;
const map = new Map();
visible.forEach(id => {
const hidden = (childrenOf.get(id) || []).filter(k => !visible.has(k)).length;
if (hidden > 0) map.set(id, hidden);
});
return map;
});
// 依据展开状态计算实际展示的节点与连线
const displayData = computed(() => {
const visible = visibleCatIds.value;
const docCat = new Map(); // 文档节点 → 所属分类 ID
links.value.forEach(l => {
if (
l.kind === "belong" &&
String(l.source).startsWith("d") &&
String(l.target).startsWith("c")
) {
docCat.set(l.source, Number(String(l.target).slice(1)));
}
});
const catNodes = [];
const docNodes = [];
nodes.value.forEach(n => {
if (Number(n.category) >= 1) {
if (visible.has(Number(n.category_id))) catNodes.push(n);
} else if (filters.showDocs) {
const cid = docCat.get(n.id);
if (cid !== undefined && visible.has(cid)) docNodes.push(n);
}
});
const shown = new Set([...catNodes, ...docNodes].map(n => n.id));
const shownLinks = links.value.filter(
l => shown.has(l.source) && shown.has(l.target)
);
return { catNodes, docNodes, shownLinks };
});
const displayNodes = computed(() => [
...displayData.value.catNodes,
...displayData.value.docNodes
]);
const displayLinks = computed(() => displayData.value.shownLinks);
function isDark() {
return document.documentElement.classList.contains("dark");
}
@@ -196,20 +336,52 @@ function buildOption() {
const subTextColor = dark ? "#a3a6ad" : "#909399";
const axisColor = dark ? "#3a3a3a" : "#e0e6ed";
const data = nodes.value.map(n => {
const isCategory = n.category === 1;
const size = isCategory
? 42
: Math.min(58, 22 + (n.degree || 0) * 5);
const sourceNodes = displayNodes.value;
const sourceLinks = displayLinks.value;
const total = sourceNodes.length;
const dense = total > 160; // 节点较多时缩小节点,减少重叠
const childrenOf = catIndex.value.childrenOf;
const data = sourceNodes.map(n => {
const isCategory = Number(n.category) >= 1;
const isPrivateCat = Number(n.category) === 2;
const cid = Number(n.category_id);
const hasChildren =
isCategory && (childrenOf.get(cid) || []).length > 0;
const isExpanded = isCategory && expandedIds.value.has(cid);
const hidden = isCategory ? hiddenChildCounts.value.get(cid) || 0 : 0;
const base = dense ? 16 : 22;
const cap = dense ? 40 : 58;
let size = isCategory
? dense
? 34
: 42
: Math.min(cap, base + (n.degree || 0) * 5);
if (isCategory && hasChildren) size += 6; // 有子级的分类节点更大,便于点击展开
let displayName = n.name;
if (isCategory) {
const mark = hasChildren ? (isExpanded ? " ▾" : " ▸") : "";
const plus = hidden > 0 ? ` +${hidden}` : "";
const priv = isPrivateCat ? "私·" : "";
displayName = `${priv}${n.name}${mark}${plus}`;
}
const depth = isCategory ? catIndex.value.depthOf.get(cid) || 1 : 0;
const catColor = categoryColor(depth);
return {
id: n.id,
name: n.name,
displayName,
category: n.category,
symbolSize: size,
value: n.value,
// 供 tooltip / 点击回查
docId: n.doc_id,
categoryId: n.category_id,
hasChildren,
depth,
isPrivateCat,
docType: n.doc_type,
status: n.status,
ext: n.ext,
@@ -218,15 +390,16 @@ function buildOption() {
viewCount: n.view_count,
degree: n.degree,
itemStyle: {
color: isCategory ? "#e6a23c" : docTypeColor(n.doc_type),
borderColor: dark ? "#1f1f1f" : "#fff",
borderWidth: 2,
shadowBlur: 10,
shadowColor: dark ? "rgba(0,0,0,0.5)" : "rgba(0,0,0,0.15)"
// 分类按层级配色(1/2/3 级各不相同),文档按类型配色;纯色无边框
color: isCategory ? catColor : docTypeColor(n.doc_type),
borderWidth: 0,
shadowBlur: 8,
shadowColor: dark ? "rgba(0,0,0,0.4)" : "rgba(0,0,0,0.12)"
},
label: {
show: true,
position: "right",
formatter: () => displayName,
fontSize: isCategory ? 13 : 12,
fontWeight: isCategory ? 600 : 400,
color: textColor
@@ -234,14 +407,15 @@ function buildOption() {
};
});
const edgeList = links.value.map(l => ({
const edgeList = sourceLinks.map(l => ({
source: l.source,
target: l.target,
value: l.label,
lineStyle: {
width: l.kind === "belong" ? 1 : 1.6,
type: l.kind === "belong" ? "dashed" : "solid",
opacity: l.kind === "belong" ? 0.45 : 0.8,
width: l.kind === "doc" ? 1.6 : 1,
type:
l.kind === "belong" ? "dashed" : l.kind === "cate" ? "dotted" : "solid",
opacity: l.kind === "doc" ? 0.8 : 0.45,
curveness: 0.12
}
}));
@@ -258,8 +432,14 @@ function buildOption() {
return params.data?.value ? `关系:${params.data.value}` : "";
}
const d = params.data;
if (d.category === 1) {
return `<b>${d.name}</b><br/>分类中心 · ${d.degree || 0} 个关联`;
if (Number(d.category) >= 1) {
const kind = Number(d.category) === 2 ? "私密分类" : "共享分类";
const tip = d.hasChildren
? `<br/>点击${d.isExpanded ? "收起" : "展开下级分类"}`
: "";
return `<b>${d.name}</b><br/>${d.depth || 1} 级分类 · ${kind} · ${
d.degree || 0
} 个关联${tip}`;
}
return [
`<b>${d.name}</b>`,
@@ -274,20 +454,32 @@ function buildOption() {
series: [
{
type: "graph",
layout: "force",
layout: filters.layout,
// roam: 滚轮缩放 + 拖拽平移;按住空格时禁用节点拖拽,让左键拖拽整体平移
roam: true,
draggable: true,
scaleLimit: { min: 0.15, max: 6 },
draggable: !spaceHeld.value,
data,
links: edgeList,
categories: [
{ name: "文档", itemStyle: { color: "#409eff" } },
{ name: "分类", itemStyle: { color: "#e6a23c" } }
{ name: "共享分类", itemStyle: { color: "#e6a23c" } },
{ name: "私密分类", itemStyle: { color: "#b37feb" } }
],
force: {
repulsion: nodes.value.length > 200 ? 90 : 160,
edgeLength: [70, 150],
gravity: 0.08
},
// 标签重叠时自动隐藏,避免文字糊在一起
labelLayout: { hideOverlap: true },
force:
filters.layout === "force"
? {
// 斥力随节点数放大,节点越多铺得越开,避免挤成一团
repulsion: Math.max(400, total * 14),
edgeLength: dense ? [140, 280] : [100, 200],
gravity: 0.06,
layoutAnimation: true
}
: undefined,
circular:
filters.layout === "circular" ? { rotateLabel: true } : undefined,
emphasis: {
focus: "adjacency",
scale: 1.15,
@@ -302,32 +494,45 @@ function buildOption() {
};
}
function renderChart() {
function renderChart(reset = true) {
if (!chartRef.value) return;
if (!chart) {
chart = echarts.init(chartRef.value);
chart.on("click", handleChartClick);
}
chart.setOption(buildOption(), true);
// 展开/收起时使用合并更新(reset=false),保留当前缩放与平移
chart.setOption(buildOption(), { notMerge: reset, lazyUpdate: false });
}
function fitView() {
// 力导向图无内置"复位"动作,重新渲染即可回到初始布局与缩放
renderChart();
// 重新渲染回到初始布局与缩放
renderChart(true);
}
function collapseAll() {
expandedIds.value = new Set();
renderChart(true);
}
function handleChartClick(params) {
if (params.dataType !== "node") return;
openNode(params.data);
const d = params.data;
if (Number(d.category) >= 1) {
// 分类节点:有子级则展开 / 收起,展开后在该分支继续延展 3 级
const cid = Number(d.categoryId);
const kids = catIndex.value.childrenOf.get(cid) || [];
if (!kids.length) return;
const next = new Set(expandedIds.value);
if (next.has(cid)) next.delete(cid);
else next.add(cid);
expandedIds.value = next;
renderChart(false);
return;
}
openNode(d);
}
function openNode(node) {
if (Number(node.category) === 1) {
// 分类节点:按该分类过滤图谱
filters.category_id = node.categoryId ?? node.category_id ?? null;
loadGraph();
return;
}
const docId = node.docId ?? node.doc_id;
if (docId) {
detailId.value = docId;
@@ -335,13 +540,71 @@ function openNode(node) {
}
}
async function loadCategories() {
const res = await getDocCategories();
if (res?.code === 200) {
categories.value = res.data || [];
// ---------------- 空格 + 鼠标左键拖拽平移 ----------------
function isTypingTarget(el) {
return (
!!el &&
(el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.isContentEditable === true)
);
}
function applyPanMode(on) {
if (chartRef.value) chartRef.value.style.cursor = on ? "grab" : "";
try {
// 空格按下时禁用节点拖拽,使左键拖拽变为整体平移
chart?.setOption({ series: [{ draggable: !on }] });
} catch {
/* ignore */
}
}
function onSpaceDown(e) {
if (e.code !== "Space") return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
if (spaceHeld.value) return;
spaceHeld.value = true;
applyPanMode(true);
}
function onSpaceUp(e) {
if (e.code !== "Space") return;
spaceHeld.value = false;
applyPanMode(false);
}
async function loadCategories() {
// 图谱分类筛选需同时包含共享分类与当前用户的私密分类,按分组展示
const [sharedRes, personalRes] = await Promise.all([
getDocCategories({ scope: "shared" }),
getDocCategories({ scope: "personal" })
]);
const shared = sharedRes?.code === 200 ? sharedRes.data || [] : [];
const personal = personalRes?.code === 200 ? personalRes.data || [] : [];
sharedTree.value = shared;
personalTree.value = personal;
const groups = [];
if (shared.length) {
groups.push({
id: "grp-shared",
name: "共享分类",
disabled: true,
children: shared
});
}
if (personal.length) {
groups.push({
id: "grp-personal",
name: "私密分类",
disabled: true,
children: personal
});
}
categories.value = groups;
}
async function loadGraph() {
loading.value = true;
try {
@@ -375,8 +638,11 @@ function resetFilters() {
filters.category_id = null;
filters.relation = "";
filters.limit = 300;
filters.layout = "force";
filters.includeCategory = true;
filters.onlyLinked = false;
filters.showDocs = true;
expandedIds.value = new Set();
loadGraph();
}
@@ -391,22 +657,34 @@ function handleResize() {
chart?.resize();
}
onMounted(() => {
loadCategories();
loadGraph();
// "显示文档"开关即时生效(保留当前缩放/平移)
watch(
() => filters.showDocs,
() => renderChart(false)
);
onMounted(async () => {
// 先加载分类树(分层展示依赖它),再加载图谱数据
await loadCategories();
await loadGraph();
nextTick(() => {
if (!chartRef.value) return;
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartRef.value);
});
themeObserver = new MutationObserver(() => renderChart());
themeObserver = new MutationObserver(() => renderChart(false));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
// 空格 + 左键拖拽平移
window.addEventListener("keydown", onSpaceDown);
window.addEventListener("keyup", onSpaceUp);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onSpaceDown);
window.removeEventListener("keyup", onSpaceUp);
resizeObserver?.disconnect();
themeObserver?.disconnect();
chart?.dispose();
@@ -519,6 +797,10 @@ onBeforeUnmount(() => {
&.dashed {
border-top: 2px dashed #c0c4cc;
}
&.dotted {
border-top: 2px dotted #c0c4cc;
}
}
.legend-tip {
@@ -391,8 +391,8 @@ async function loadDetail(id) {
}
function categoryName(id) {
if (!id) return "未分类";
return findCategory(props.categories, id)?.name || "未分类";
if (!id) return "-";
return findCategory(props.categories, id)?.name || "-";
}
function peer(row) {
@@ -0,0 +1,412 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑文档' : '新建文档'"
class="doc-editor-dialog"
:class="{ 'is-fullscreen': fullscreen }"
width="78%"
top="5vh"
:fullscreen="fullscreen"
:close-on-click-modal="false"
destroy-on-close
>
<template #header="{ titleId, titleClass }">
<div class="doc-editor-header">
<span :id="titleId" :class="titleClass">
{{ isEdit ? "编辑文档" : "新建文档" }}
</span>
<el-button
link
:icon="FullScreen"
class="doc-editor-fs"
@click="fullscreen = !fullscreen"
>
{{ fullscreen ? "退出全屏" : "全屏" }}
</el-button>
</div>
</template>
<el-form :model="form" label-width="72px" class="doc-editor-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="标题" required>
<el-input
v-model="form.title"
maxlength="120"
show-word-limit
placeholder="请输入文档标题"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="分类" required>
<el-tree-select
v-model="form.category_id"
:data="categories"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="请选择分类"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option
v-for="s in DOC_STATUS_OPTIONS"
:key="s.value"
:label="s.label"
:value="s.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="标签">
<el-select
v-model="form.tagList"
multiple
filterable
allow-create
default-first-option
placeholder="选择或输入标签"
style="width: 100%"
>
<el-option v-for="t in presetTags" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="负责人">
<el-input v-model="form.owner_name" placeholder="负责人" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility" :disabled="true">
<el-radio :value="0">公开</el-radio>
<el-radio :value="1">私密</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="摘要">
<el-input
v-model="form.summary"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="选填"
/>
</el-form-item>
<el-form-item label="正文">
<div
v-loading="contentLoading"
class="doc-editor-box"
:style="editorBoxStyle"
>
<UmoEditor v-if="ready" v-model="form.content" />
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="doc-editor-footer">
<span class="doc-editor-hint">
{{ props.scope === "personal" ? "保存为私密文档(绑定个人)" : "保存为共享文档(租户内可见)" }}
· 以 Word(docx)格式保存
</span>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? "保存" : "创建" }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { FullScreen } from "@element-plus/icons-vue";
import { uploadFile } from "@/api/file";
import { createDoc, getDocContent, updateDoc } from "@/api/oaDocument";
import { DOC_STATUS_OPTIONS, getFileUrl, parseTags } from "../../utils";
const DOCX_MIME =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const props = defineProps({
modelValue: { type: Boolean, default: false },
scope: { type: String, default: "shared" },
categories: { type: Array, default: () => [] },
defaultCategoryId: { type: Number, default: 0 },
currentUserName: { type: String, default: "" },
// 传入则为编辑已有在线文档,否则为新建
doc: { type: Object, default: null }
});
const emit = defineEmits(["update:modelValue", "saved"]);
const presetTags = ["制度", "流程", "模板", "合同", "培训", "报表", "归档"];
const visible = ref(props.modelValue);
const fullscreen = ref(false);
const ready = ref(false);
const contentLoading = ref(false);
const submitting = ref(false);
const isEdit = computed(() => !!props.doc?.id);
const lockedVisibility = computed(() => (props.scope === "personal" ? 1 : 0));
const editorBoxStyle = computed(() =>
fullscreen.value ? { height: "calc(100vh - 210px)" } : {}
);
const form = reactive({
title: "",
category_id: 0,
tagList: [],
status: 1,
visibility: 0,
owner_name: "",
summary: "",
content: ""
});
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val) open();
}
);
watch(visible, val => emit("update:modelValue", val));
async function open() {
fullscreen.value = false;
ready.value = false;
contentLoading.value = false;
Object.assign(form, {
title: props.doc?.title || "",
category_id: props.doc?.category_id || props.defaultCategoryId || 0,
tagList: parseTags(props.doc?.tags),
status: props.doc?.status ?? 1,
visibility: lockedVisibility.value,
owner_name: props.doc?.owner_name || props.currentUserName || "",
summary: props.doc?.summary || "",
content: ""
});
if (isEdit.value) {
contentLoading.value = true;
try {
let html = "";
const res = await getDocContent(props.doc.id);
if (res?.code === 200) html = res.data?.content || "";
// 兼容早期以 html 文件保存的在线文档
if (
!html &&
props.doc?.file_url &&
["html", "htm"].includes(String(props.doc.ext || "").toLowerCase())
) {
html = await fetchText(getFileUrl(props.doc.file_url));
}
form.content = absolutizeUrls(extractBody(html));
} catch (e) {
ElMessage.error("文档内容加载失败,可重新编辑后覆盖保存");
} finally {
contentLoading.value = false;
}
}
ready.value = true;
}
async function handleSubmit() {
const title = String(form.title || "").trim();
if (!title) {
ElMessage.warning("请输入文档标题");
return;
}
if (!form.category_id) {
ElMessage.warning("请选择所属分类");
return;
}
if (!hasContent(form.content)) {
ElMessage.warning("请输入文档正文");
return;
}
submitting.value = true;
try {
// 图片地址转为绝对地址,便于导出 docx 时抓取
const html = absolutizeUrls(form.content);
let docxBlob;
try {
const { default: HTMLtoDOCX } = await import("@turbodocx/html-to-docx");
const out = await HTMLtoDOCX(html, null, {
font: "宋体",
fontSize: 24,
title,
creator: props.currentUserName || undefined,
footer: false,
pageNumber: false,
table: { row: { cantSplit: true } },
decodeUnicode: true
});
docxBlob = out instanceof Blob ? out : new Blob([out], { type: DOCX_MIME });
} catch (e) {
ElMessage.error("生成 Word 文档失败:" + (e?.message || ""));
return;
}
const fileName = `${safeFileName(title)}.docx`;
const file = new File([docxBlob], fileName, { type: DOCX_MIME });
const fd = new FormData();
fd.append("file", file);
fd.append("cate", "0");
const up = await uploadFile(fd);
const upData = up?.data || {};
if (up?.code !== 200 && up?.code !== 201) {
ElMessage.error(up?.msg || "文档保存失败");
return;
}
const payload = {
title,
category_id: form.category_id || 0,
tags: (form.tagList || []).join(","),
summary: form.summary || "",
status: form.status,
visibility: form.visibility,
owner_name: form.owner_name || "",
file_id: upData.id || 0,
file_url: upData.url || "",
file_name: fileName,
ext: "docx",
size: docxBlob.size,
content: html
};
const res = isEdit.value
? await updateDoc(props.doc.id, payload)
: await createDoc(payload);
if (res?.code === 200) {
ElMessage.success(isEdit.value ? "已保存" : "创建成功");
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "保存失败");
}
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function fetchText(url) {
const resp = await fetch(url, { mode: "cors" });
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
return await resp.text();
}
/** 判断编辑器内容是否为空(去掉标签与空白后仍有字符) */
function hasContent(html) {
return (
String(html || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, "")
.replace(/\s/g, "").length > 0
);
}
/** 从完整 HTML 文档中取出 body 内容,便于回填编辑器 */
function extractBody(html) {
const s = String(html || "");
if (!/<body[\s>]/i.test(s)) return s;
try {
return new DOMParser().parseFromString(s, "text/html").body.innerHTML;
} catch {
return s;
}
}
/** 将相对图片地址补全为绝对地址(编辑器/导出 docx 都需要能直接访问) */
function absolutizeUrls(html) {
const s = String(html || "");
if (!s) return s;
const base = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
if (!base) return s;
try {
const doc = new DOMParser().parseFromString(s, "text/html");
doc.querySelectorAll("img").forEach(img => {
const src = img.getAttribute("src") || "";
if (src && !/^(https?:|data:|blob:)/i.test(src)) {
img.setAttribute("src", base + (src.startsWith("/") ? src : "/" + src));
}
});
return doc.body.innerHTML;
} catch {
return s;
}
}
function safeFileName(name) {
return String(name || "文档")
.replace(/[\\/:*?"<>|]/g, "_")
.slice(0, 80);
}
</script>
<style scoped>
.doc-editor-dialog :deep(.el-dialog__body) {
padding-top: 8px;
}
.doc-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.doc-editor-fs {
font-size: 13px;
}
.doc-editor-box {
width: 100%;
height: 460px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.doc-editor-box :deep(.umo-editor),
.doc-editor-box :deep(umo-editor) {
height: 100%;
}
.doc-editor-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.doc-editor-hint {
margin-right: auto;
font-size: 12px;
color: #909399;
}
</style>
@@ -0,0 +1,543 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
class="doc-preview-dialog"
width="82%"
top="5vh"
:fullscreen="fullscreen"
destroy-on-close
@closed="handleClosed"
>
<template #header="{ titleId, titleClass }">
<div class="pv-header">
<span :id="titleId" :class="titleClass">{{ dialogTitle }}</span>
<el-button
link
:icon="fullscreen ? Close : FullScreen"
class="pv-fs"
@click="fullscreen = !fullscreen"
>
{{ fullscreen ? "退出全屏" : "全屏" }}
</el-button>
</div>
</template>
<div class="pv-toolbar">
<div class="pv-title">
<el-tag size="small" effect="plain">{{ kindLabel }}</el-tag>
<span class="pv-name" :title="fileName">{{ fileName }}</span>
<span v-if="fileSize" class="pv-size">{{ fileSize }}</span>
</div>
<div class="pv-ops">
<el-button
v-if="kind === 'image'"
size="small"
:icon="ZoomIn"
@click="toggleImageFit"
>{{ imageFit === "original" ? "适应宽度" : "原始大小" }}</el-button
>
<el-button
size="small"
:icon="RefreshRight"
:disabled="loading"
@click="reload"
>重新加载</el-button
>
<el-button
size="small"
type="primary"
:icon="Download"
@click="emit('download', doc)"
>下载</el-button
>
</div>
</div>
<div
v-loading="loading"
element-loading-text="正在加载文档..."
class="pv-stage"
:style="stageStyle"
>
<template v-if="!loading">
<!-- 加载失败:引导下载 -->
<el-empty v-if="error" description="无法在线预览">
<p class="pv-tip">{{ error }}</p>
<el-button type="primary" :icon="Download" @click="emit('download', doc)"
>下载后查看</el-button
>
</el-empty>
<!-- 图片:默认适应宽度,可切原始大小;超出容器时滚动查看 -->
<img
v-else-if="kind === 'image'"
:src="fileUrl"
class="pv-image"
:style="imageStyle"
/>
<!-- PDF:交给浏览器内置阅读器 -->
<iframe
v-else-if="kind === 'pdf'"
:src="fileUrl"
class="pv-frame"
frameborder="0"
:style="frameStyle"
/>
<!-- 视频 -->
<video
v-else-if="kind === 'video'"
:src="fileUrl"
controls
class="pv-media"
:style="mediaStyle"
/>
<!-- 音频 -->
<audio
v-else-if="kind === 'audio'"
:src="fileUrl"
controls
class="pv-audio"
/>
<!-- Word(docx) -->
<div v-else-if="kind === 'docx'" ref="docxBox" class="pv-docx"></div>
<!-- Excel / CSV -->
<div v-else-if="kind === 'sheet'" class="pv-sheet">
<div v-for="s in sheets" :key="s.name" class="pv-sheet-item">
<div class="pv-sheet-name">{{ s.name }}</div>
<div class="pv-sheet-html" v-html="s.html"></div>
</div>
</div>
<!-- 纯文本 -->
<pre v-else-if="kind === 'text'" class="pv-text">{{ textContent }}</pre>
<!-- Markdown / HTML -->
<div v-else-if="kind === 'html'" class="pv-html" v-html="safeHtml"></div>
<!-- 其他类型 -->
<el-empty v-else :description="`暂不支持在线预览 .${ext || ''} 文件`">
<p class="pv-tip">该格式无法在浏览器中安全渲染,请下载后使用本地软件打开。</p>
<el-button type="primary" :icon="Download" @click="emit('download', doc)"
>下载后查看</el-button
>
</el-empty>
</template>
</div>
</el-dialog>
</template>
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import {
Close,
Download,
FullScreen,
RefreshRight,
ZoomIn
} from "@element-plus/icons-vue";
import { formatFileSize, getFileUrl } from "../../utils";
const props = defineProps({
modelValue: { type: Boolean, default: false },
doc: { type: Object, default: null }
});
const emit = defineEmits(["update:modelValue", "download"]);
const visible = ref(props.modelValue);
const fullscreen = ref(false);
const imageFit = ref("width"); // width=适应宽度;original=原始大小
const loading = ref(false);
const error = ref("");
const textContent = ref("");
const safeHtml = ref("");
const sheets = ref([]);
const docxBox = ref(null);
// 全屏时让内容区自适应可视区域
const stageStyle = computed(() =>
fullscreen.value ? { maxHeight: "calc(100vh - 200px)" } : {}
);
const frameStyle = computed(() =>
fullscreen.value ? { height: "calc(100vh - 250px)" } : {}
);
const mediaStyle = computed(() =>
fullscreen.value ? { maxHeight: "calc(100vh - 250px)" } : {}
);
// 图片:适应宽度时按宽度等比缩放;原始大小时保持原尺寸,超出由容器滚动查看
const imageStyle = computed(() =>
imageFit.value === "original"
? { maxWidth: "none", width: "auto", height: "auto" }
: { maxWidth: "100%", width: "auto", height: "auto" }
);
function toggleImageFit() {
imageFit.value = imageFit.value === "original" ? "width" : "original";
}
const ext = computed(() => String(props.doc?.ext || "").toLowerCase());
const fileUrl = computed(() => getFileUrl(props.doc?.file_url || ""));
const fileName = computed(
() => props.doc?.file_name || props.doc?.title || "文档"
);
const dialogTitle = computed(() => props.doc?.title || fileName.value);
const fileSize = computed(() =>
props.doc?.size ? formatFileSize(props.doc.size) : ""
);
const IMAGE_EXT = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"];
const VIDEO_EXT = ["mp4", "webm", "ogv", "mov", "m4v", "avi", "mkv"];
const AUDIO_EXT = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
const SHEET_EXT = ["xls", "xlsx", "csv"];
const TEXT_EXT = [
"txt",
"log",
"json",
"xml",
"yml",
"yaml",
"ini",
"conf",
"js",
"ts",
"css",
"sql",
"sh"
];
const HTML_EXT = ["md", "markdown", "html", "htm"];
const kind = computed(() => {
const e = ext.value;
if (!e) return "unknown";
if (IMAGE_EXT.includes(e)) return "image";
if (e === "pdf") return "pdf";
if (VIDEO_EXT.includes(e)) return "video";
if (AUDIO_EXT.includes(e)) return "audio";
if (e === "docx") return "docx";
if (SHEET_EXT.includes(e)) return "sheet";
if (e === "md" || e === "markdown" || e === "html" || e === "htm")
return "html";
if (TEXT_EXT.includes(e)) return "text";
return "unknown";
});
const kindLabel = computed(
() =>
({
image: "图片",
pdf: "PDF",
video: "视频",
audio: "音频",
docx: "Word",
sheet: "表格",
html: "文本",
text: "文本",
unknown: "文件"
})[kind.value] || "文件"
);
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val) {
fullscreen.value = false;
imageFit.value = "width";
start();
}
}
);
watch(visible, val => emit("update:modelValue", val));
// 弹窗已打开时切换文档,重新加载预览内容
watch(
() => props.doc,
() => {
if (visible.value) start();
}
);
function reload() {
start();
}
function reset() {
error.value = "";
textContent.value = "";
safeHtml.value = "";
sheets.value = [];
if (docxBox.value) docxBox.value.innerHTML = "";
}
async function start() {
reset();
if (!fileUrl.value) {
error.value = "该文档没有可预览的附件";
loading.value = false;
return;
}
// 图片 / PDF / 音视频由浏览器直接渲染,无需预取
if (["image", "pdf", "video", "audio", "unknown"].includes(kind.value)) {
loading.value = false;
return;
}
loading.value = true;
try {
if (kind.value === "docx") {
const buf = await fetchBuffer(fileUrl.value);
loading.value = false;
await nextTick();
const { renderAsync } = await import("docx-preview");
await renderAsync(buf, docxBox.value, null, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
useBase64URL: true
});
} else if (kind.value === "sheet") {
const buf = await fetchBuffer(fileUrl.value);
const XLSX = await import("xlsx");
const wb = XLSX.read(buf, { type: "array" });
sheets.value = (wb.SheetNames || []).map(name => ({
name,
html: tableOnly(XLSX.utils.sheet_to_html(wb.Sheets[name]))
}));
loading.value = false;
} else if (kind.value === "html") {
const txt = await fetchText(fileUrl.value);
safeHtml.value =
ext.value === "md" || ext.value === "markdown"
? sanitize((await import("marked")).marked.parse(txt))
: sanitize(txt);
loading.value = false;
} else if (kind.value === "text") {
textContent.value = await fetchText(fileUrl.value);
loading.value = false;
}
} catch (e) {
error.value = e?.message ? `加载失败:${e.message}` : "加载失败";
loading.value = false;
}
}
async function fetchBuffer(url) {
const resp = await fetch(url, { mode: "cors" });
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
return await resp.arrayBuffer();
}
async function fetchText(url) {
const resp = await fetch(url, { mode: "cors" });
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
return await resp.text();
}
/** SheetJS 导出的 HTML 可能含 <html>/<body> 外壳,这里只保留 <table> */
function tableOnly(html) {
const m = /<table[\s\S]*<\/table>/i.exec(String(html || ""));
return m ? m[0] : html;
}
/** 轻量 HTML 清洗:移除脚本/内联事件等危险内容,避免上传文档造成 XSS */
function sanitize(html) {
const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
doc
.querySelectorAll("script,iframe,object,embed,link,style,meta,base")
.forEach(n => n.remove());
doc.querySelectorAll("*").forEach(el => {
[...el.attributes].forEach(attr => {
const name = attr.name.toLowerCase();
const val = String(attr.value || "")
.replace(/\s+/g, "")
.toLowerCase();
if (name.startsWith("on")) el.removeAttribute(attr.name);
if (
(name === "href" || name === "src" || name === "xlink:href") &&
val.startsWith("javascript:")
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}
function handleClosed() {
reset();
}
</script>
<style scoped>
.doc-preview-dialog :deep(.el-dialog__body) {
padding-top: 8px;
}
.pv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pv-fs {
font-size: 13px;
}
.pv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.pv-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.pv-name {
font-size: 14px;
color: #303133;
font-weight: 500;
max-width: 46vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pv-size {
font-size: 12px;
color: #909399;
}
.pv-stage {
min-height: 260px;
max-height: 74vh;
overflow: auto;
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
}
.pv-tip {
color: #909399;
font-size: 13px;
margin: 0 0 12px;
}
.pv-image {
display: block;
margin: 0 auto;
/* 实际尺寸由内联样式控制(适应宽度 / 原始大小),超出时由 .pv-stage 滚动 */
}
.pv-frame {
width: 100%;
height: 70vh;
background: #fff;
}
.pv-media {
display: block;
margin: 0 auto;
max-width: 100%;
max-height: 70vh;
background: #000;
}
.pv-audio {
display: block;
width: 100%;
margin: 24px auto;
}
.pv-docx {
background: #fff;
padding: 16px;
min-height: 200px;
}
.pv-docx :deep(.docx-wrapper) {
background: transparent;
padding: 0;
}
.pv-sheet {
background: #fff;
}
.pv-sheet-item + .pv-sheet-item {
margin-top: 16px;
}
.pv-sheet-name {
font-size: 13px;
font-weight: 600;
color: #409eff;
padding: 6px 8px;
background: #ecf5ff;
}
.pv-sheet-html {
overflow: auto;
}
.pv-sheet-html :deep(table) {
border-collapse: collapse;
font-size: 12px;
}
.pv-sheet-html :deep(td),
.pv-sheet-html :deep(th) {
border: 1px solid #dcdfe6;
padding: 2px 6px;
white-space: nowrap;
}
.pv-text {
margin: 0;
padding: 12px;
background: #fff;
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: #303133;
}
.pv-html {
background: #fff;
padding: 16px 20px;
font-size: 14px;
line-height: 1.7;
color: #303133;
word-break: break-word;
}
.pv-html :deep(img) {
max-width: 100%;
}
.pv-html :deep(table) {
border-collapse: collapse;
}
.pv-html :deep(td),
.pv-html :deep(th) {
border: 1px solid #dcdfe6;
padding: 4px 8px;
}
</style>
@@ -12,7 +12,7 @@
node-key="id"
check-strictly
clearable
placeholder="未分类"
placeholder="请选择分类"
style="width: 100%"
/>
</el-form-item>
@@ -36,12 +36,12 @@
</el-radio-group>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">租户公开</el-radio>
<el-radio :value="1">私密</el-radio>
<el-radio-group v-model="form.visibility" :disabled="true">
<el-radio :value="0">租户公开(共享文档)</el-radio>
<el-radio :value="1">私密(私密文档)</el-radio>
</el-radio-group>
<div class="el-upload__tip">
私密文档仅本人及被共享的用户/部门可见;设为私密后可在详情中设置共享范围。
可见性由当前所在空间决定(共享文档=公开,私密文档=仅自己及被共享者),如需改变空间请重新上传。
</div>
</el-form-item>
<el-form-item label="版本号">
@@ -79,7 +79,9 @@ import { parseTags } from "../../utils";
const props = defineProps({
modelValue: { type: Boolean, default: false },
doc: { type: Object, default: null },
categories: { type: Array, default: () => [] }
categories: { type: Array, default: () => [] },
// 当前文档空间:shared=共享文档 personal=私密文档(决定可见性,且分类树已隔离)
scope: { type: String, default: "shared" }
});
const emit = defineEmits(["update:modelValue", "saved"]);
@@ -107,7 +109,7 @@ watch(
category_id: props.doc.category_id || 0,
tagList: parseTags(props.doc.tags),
status: props.doc.status ?? 0,
visibility: props.doc.visibility ?? 0,
visibility: props.scope === "personal" ? 1 : 0,
version: props.doc.version || 1,
owner_name: props.doc.owner_name || "",
summary: props.doc.summary || ""
@@ -2,7 +2,7 @@
<el-dialog
v-model="visible"
title="上传文档"
width="620px"
width="720px"
:close-on-click-modal="false"
@closed="handleClosed"
>
@@ -11,41 +11,86 @@
<el-upload
ref="uploadRef"
drag
multiple
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
:show-file-list="false"
:on-change="handleChange"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp,.mp4,.mp3"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
<div class="el-upload__text">
将文件拖到此处,或<em>点击选择</em>(支持批量)
</div>
<template #tip>
<div class="el-upload__tip">
支持 PDF / Office / 图片 / 压缩包等,单个文件不超过 200MB
支持 PDF / Office / 图片 / 压缩包等,单个文件不超过 200MB;文档标题默认取文件名(不含扩展名),可在下方列表修改
</div>
</template>
</el-upload>
<el-progress
v-if="uploading"
:percentage="uploadPercent"
:stroke-width="14"
striped
style="margin-top: 10px"
/>
</el-form-item>
<el-form-item label="文档标题">
<el-input
v-model="form.title"
maxlength="120"
show-word-limit
placeholder="留空则使用文件名"
/>
<el-form-item v-if="fileList.length" label="文件列表">
<el-table
:data="fileList"
size="small"
border
max-height="240"
style="width: 100%"
>
<el-table-column label="文档标题" min-width="200">
<template #default="{ row }">
<el-input
v-model="row.title"
size="small"
maxlength="120"
placeholder="默认使用文件名"
/>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{
formatFileSize(row.size || row.raw?.size)
}}</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ $index }">
<el-button
link
type="danger"
:disabled="uploading"
@click="removeFile($index)"
>移除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="upload-tip">
共 {{ fileList.length }} 个文件
<el-button
link
type="primary"
:disabled="uploading"
@click="clearFiles"
>清空</el-button
>
</div>
</el-form-item>
<el-form-item label="所属分类">
<el-form-item v-if="uploading || uploadPercent > 0" label="进度">
<div style="width: 100%">
<el-progress
:percentage="uploadPercent"
:stroke-width="14"
striped
/>
<div class="upload-tip">{{ progressText }}</div>
</div>
</el-form-item>
<el-form-item label="所属分类" required>
<el-tree-select
v-model="form.category_id"
:data="categoryTree"
@@ -53,9 +98,12 @@
node-key="id"
check-strictly
clearable
placeholder="未分类"
placeholder="请选择分类(应用于全部文件)"
style="width: 100%"
/>
<div v-if="!categoryTree.length" class="upload-tip">
暂无分类,请先在左侧「文档分类」中新建分类后再上传
</div>
</el-form-item>
<el-form-item label="标签">
@@ -65,7 +113,7 @@
filterable
allow-create
default-first-option
placeholder="输入后回车可新建标签"
placeholder="输入后回车可新建标签(应用于全部文件)"
style="width: 100%"
>
<el-option v-for="t in presetTags" :key="t" :label="t" :value="t" />
@@ -81,12 +129,18 @@
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">租户公开</el-radio>
<el-radio :value="1">私密</el-radio>
<el-radio-group v-model="form.visibility" :disabled="true">
<el-radio :value="0">团队公开(共享文档)</el-radio>
<el-radio :value="1">私密(仅自己及被共享者可见)</el-radio>
</el-radio-group>
<div class="el-upload__tip">
私密文档仅本人及被共享的用户/部门可见;设为私密后可在详情中设置共享范围。
当前所在空间已决定归属:
<template v-if="props.scope === 'personal'"
>私密文档绑定到你的个人空间,仅自己及被显式共享的用户/部门可见,可在详情中设置共享范围。</template
>
<template v-else
>共享文档对租户内所有人可见,归属到下方所选的共享分类。</template
>
</div>
</el-form-item>
@@ -102,22 +156,22 @@
<el-input
v-model="form.summary"
type="textarea"
:rows="3"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填,简要描述文档内容"
placeholder="选填,简要描述文档内容(应用于全部文件)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button :disabled="uploading" @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="uploading"
:disabled="uploading || !fileList.length"
@click="handleSubmit"
>保存</el-button
>保存{{ fileList.length ? `(${fileList.length})` : "" }}</el-button
>
</template>
</el-dialog>
@@ -129,29 +183,36 @@ import { ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { uploadFile } from "@/api/file";
import { createDoc } from "@/api/oaDocument";
import { formatFileSize } from "../../utils";
const props = defineProps({
modelValue: { type: Boolean, default: false },
categories: { type: Array, default: () => [] },
defaultCategoryId: { type: Number, default: 0 },
currentUserName: { type: String, default: "" }
currentUserName: { type: String, default: "" },
// 当前文档空间:shared=共享文档(固定公开)personal=私密文档(固定私密,绑定用户级别)
scope: { type: String, default: "shared" },
// 默认可见性:0-租户公开(共享文档)1-私密(个人文档)
defaultVisibility: { type: Number, default: 0 }
});
const emit = defineEmits(["update:modelValue", "saved"]);
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
const MAX_COUNT = 100;
const treeProps = { label: "name", children: "children" };
const presetTags = ["制度", "流程", "模板", "合同", "培训", "报表", "归档"];
const visible = ref(props.modelValue);
const uploadRef = ref(null);
const fileList = ref([]);
const rawFile = ref(null);
const uploading = ref(false);
const uploadPercent = ref(0);
const progressText = ref("");
const submitting = ref(false);
const form = ref({
title: "",
category_id: 0,
tagList: [],
status: 1,
@@ -161,119 +222,187 @@ const form = ref({
});
const categoryTree = computed(() => props.categories || []);
// 可见性随空间锁定:共享空间固定公开,私密空间固定私密(分类树已按空间隔离)
const lockedVisibility = computed(() =>
props.scope === "personal" ? 1 : 0
);
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val) {
fileList.value = [];
uploadPercent.value = 0;
progressText.value = "";
form.value = {
title: "",
category_id: props.defaultCategoryId || 0,
tagList: [],
status: 1,
visibility: 0,
visibility: lockedVisibility.value,
owner_name: props.currentUserName || "",
summary: ""
};
uploadPercent.value = 0;
}
}
);
watch(visible, val => emit("update:modelValue", val));
function handleFileChange(file) {
rawFile.value = file.raw || file;
fileList.value = [file];
if (!form.value.title) {
form.value.title = String(file.name || "").replace(/\.[^.]+$/, "");
}
// 选择文件后加入批量列表;标题默认取文件名(不含扩展名),可在此修改
function handleChange(file) {
if (!file) return;
if (fileList.value.some(f => f.uid === file.uid)) return;
fileList.value.push({
uid: file.uid,
name: file.name,
size: file.size || file.raw?.size || 0,
raw: file.raw || file,
title: String(file.name || "").replace(/\.[^.]+$/, "")
});
}
function handleFileRemove() {
rawFile.value = null;
function removeFile(index) {
fileList.value.splice(index, 1);
}
function clearFiles() {
fileList.value = [];
uploadRef.value?.clearFiles?.();
}
function handleExceed() {
ElMessage.warning("单次只能上传 1 个文件");
}
function buildFormData() {
function buildFormData(raw) {
const fd = new FormData();
fd.append("file", rawFile.value);
fd.append("file", raw);
fd.append("cate", "0");
fd.append("scope", "tenant");
return fd;
}
async function handleSubmit() {
if (!rawFile.value) {
const files = fileList.value.slice();
if (!files.length) {
ElMessage.warning("请选择要上传的文件");
return;
}
if (rawFile.value.size > 200 * 1024 * 1024) {
ElMessage.warning("单个文件不能超过 200MB");
if (files.length > MAX_COUNT) {
ElMessage.warning(`单次最多上传 ${MAX_COUNT} 个文件`);
return;
}
const oversize = files.filter(
f => (f.size || f.raw?.size || 0) > MAX_SIZE
);
if (oversize.length) {
ElMessage.warning(
`以下文件超过 200MB:${oversize
.map(f => f.name)
.slice(0, 3)
.join("、")}${oversize.length > 3 ? " 等" : ""}`
);
return;
}
// 文档必须归属分类:未选择分类(或还没有分类)时不允许上传
if (!categoryTree.value.length) {
ElMessage.warning("暂无文档分类,请先在左侧「文档分类」中新建分类");
return;
}
if (!form.value.category_id) {
ElMessage.warning("请先选择所属分类");
return;
}
submitting.value = true;
uploading.value = true;
uploadPercent.value = 0;
try {
const upRes = await uploadFile(buildFormData(), {
onUploadProgress: e => {
if (!e?.total) return;
uploadPercent.value = Math.min(
99,
Math.round((e.loaded * 100) / e.total)
);
}
});
// 秒传命中时返回 code=201,同样携带 url/id/name
const upData = upRes?.data || {};
if (upRes?.code !== 200 && upRes?.code !== 201) {
ElMessage.error(upRes?.msg || "文件上传失败");
return;
}
uploadPercent.value = 100;
const name = upData.name || rawFile.value.name || "";
const ext = name.includes(".") ? name.split(".").pop() : "";
const res = await createDoc({
title: form.value.title?.trim() || name.replace(/\.[^.]+$/, ""),
category_id: form.value.category_id || 0,
file_id: upData.id || 0,
file_url: upData.url || "",
file_name: name,
ext,
size: rawFile.value.size || 0,
tags: form.value.tagList.join(","),
summary: form.value.summary || "",
status: form.value.status,
visibility: form.value.visibility || 0,
owner_name: form.value.owner_name || ""
});
if (res?.code === 200) {
ElMessage.success("上传成功");
const total = files.length;
const failures = [];
let okCount = 0;
try {
for (let i = 0; i < total; i++) {
const f = files[i];
const raw = f.raw || f;
const base = (i / total) * 100;
const span = 100 / total;
progressText.value = `正在上传 ${i + 1}/${total}:${f.name}`;
try {
const upRes = await uploadFile(buildFormData(raw), {
onUploadProgress: e => {
if (!e?.total) return;
const p = Math.min(99, Math.round((e.loaded * 100) / e.total));
uploadPercent.value = Math.min(
100,
Math.round(base + (span * p) / 100)
);
}
});
// 秒传命中时返回 code=201,同样携带 url/id/name
const upData = upRes?.data || {};
if (upRes?.code !== 200 && upRes?.code !== 201) {
failures.push(`${f.name}:${upRes?.msg || "上传失败"}`);
continue;
}
const name = upData.name || raw.name || "";
const ext = name.includes(".") ? name.split(".").pop() : "";
const res = await createDoc({
title:
String(f.title || "").trim() || name.replace(/\.[^.]+$/, ""),
category_id: form.value.category_id || 0,
file_id: upData.id || 0,
file_url: upData.url || "",
file_name: name,
ext,
size: raw.size || 0,
tags: form.value.tagList.join(","),
summary: form.value.summary || "",
status: form.value.status,
visibility: form.value.visibility || 0,
owner_name: form.value.owner_name || ""
});
if (res?.code === 200) {
okCount++;
} else {
failures.push(`${f.name}:${res?.msg || "保存失败"}`);
}
} catch (err) {
failures.push(`${f.name}:${err?.message || "上传失败"}`);
}
uploadPercent.value = Math.round(((i + 1) / total) * 100);
}
if (okCount > 0) {
if (failures.length) {
ElMessage.warning(
`成功 ${okCount} 个,失败 ${failures.length} 个:${failures
.slice(0, 3)
.join(";")}${failures.length > 3 ? " 等" : ""}`
);
} else {
ElMessage.success(`成功上传 ${okCount} 个文档`);
}
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "保存失败");
ElMessage.error(
`上传失败:${failures.slice(0, 3).join(";")}${
failures.length > 3 ? " 等" : ""
}`
);
}
} catch (err) {
ElMessage.error(err?.message || "上传失败");
} finally {
uploading.value = false;
submitting.value = false;
progressText.value = "";
}
}
function handleClosed() {
rawFile.value = null;
fileList.value = [];
uploadPercent.value = 0;
progressText.value = "";
uploadRef.value?.clearFiles?.();
}
</script>
@@ -282,4 +411,14 @@ function handleClosed() {
:deep(.el-upload-dragger) {
padding: 18px 12px;
}
.upload-tip {
width: 100%;
margin-top: 6px;
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -3,16 +3,44 @@
<div class="page-header">
<div>
<h2>文档库</h2>
<p>按分类集中管理租户文档,支持上传、检索、预览、关联与归档</p>
<p>
按「共享文档 / 私密文档」两个空间分别管理:共享文档租户内可见,私密文档绑定到个人,支持在线制作、上传、检索、预览、关联与归档
</p>
</div>
<div class="header-actions">
<el-button :icon="Plus" @click="openCreateCategory()">新建分类</el-button>
<el-button :icon="EditPen" @click="openDocEditor()">新建文档</el-button>
<el-button type="primary" :icon="Upload" @click="uploadVisible = true"
>上传文档</el-button
>
</div>
</div>
<!-- 空间切换:共享文档 / 私密文档(各自独立的分类树与文档) -->
<div class="scope-tabs">
<button
type="button"
class="scope-tab"
:class="{ active: activeScope === 'shared' }"
@click="switchScope('shared')"
>
<el-icon><FolderOpened /></el-icon>
<span>共享文档</span>
</button>
<button
type="button"
class="scope-tab"
:class="{ active: activeScope === 'personal' }"
@click="switchScope('personal')"
>
<el-icon><Lock /></el-icon>
<span>私密文档</span>
</button>
<span class="scope-hint" v-if="activeScope === 'personal'"
>仅自己及被共享者可见</span
>
</div>
<!-- 概览 -->
<div class="stats-row">
<div class="stat-card">
@@ -40,7 +68,7 @@
</div>
<div class="main-layout">
<!-- 左侧:分类树 -->
<!-- 左侧:统一分类树 -->
<div class="category-pane">
<div class="pane-header">
<span>文档分类</span>
@@ -57,22 +85,17 @@
<div class="quick-list">
<div
class="quick-item"
:class="{ active: activeCategoryId === 0 && !uncategorized }"
:class="{ active: activeCategoryId === 0 }"
@click="selectQuick(0)"
>
<el-icon><Files /></el-icon>
<span>全部文档</span>
<span class="count">{{ stats.total || 0 }}</span>
</div>
<div
class="quick-item"
:class="{ active: uncategorized }"
@click="selectQuick(-1)"
>
<el-icon><FolderOpened /></el-icon>
<span>未分类</span>
<span class="count">{{ uncategorizedCount }}</span>
</div>
</div>
<div v-if="!categoryTree.length" class="tree-empty">
暂无分类,点击右上角 + 新建
</div>
<el-scrollbar class="tree-scroll">
@@ -90,6 +113,14 @@
<template #default="{ data }">
<div class="tree-node">
<span class="node-name" :title="data.name">{{ data.name }}</span>
<el-tag
v-if="isSystemCategory(data)"
size="small"
type="info"
effect="plain"
class="builtin-tag"
>内置</el-tag
>
<span class="node-count">{{ data.doc_count || 0 }}</span>
<span class="node-actions" @click.stop>
<el-button
@@ -107,6 +138,7 @@
@click="openRenameCategory(data)"
/>
<el-button
v-if="!isSystemCategory(data)"
link
size="small"
type="danger"
@@ -167,6 +199,9 @@
<el-checkbox v-model="filters.star" :true-value="1" :false-value="0"
>仅看收藏</el-checkbox
>
<el-checkbox v-model="mineFilter" :true-value="true" :false-value="false"
>仅看我创建的</el-checkbox
>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
@@ -225,12 +260,21 @@
</el-table-column>
<el-table-column label="可见性" width="80">
<template #default="{ row }">
<el-tag v-if="row.visibility === 1" type="danger" size="small" effect="plain">
私密
<el-tag
:type="row.visibility === 1 ? 'danger' : 'success'"
size="small"
effect="plain"
>
{{ activeScope === 'personal' ? '私密' : (row.visibility === 1 ? '私密' : '公开') }}
</el-tag>
<el-tag v-else type="success" size="small" effect="plain">公开</el-tag>
</template>
</el-table-column>
<el-table-column
prop="creator_name"
label="创建人"
width="100"
show-overflow-tooltip
/>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="docStatusTagType(row.status)" size="small">{{
@@ -266,10 +310,17 @@
formatDateTime(row.update_time)
}}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="handlePreview(row)">预览</el-button>
<el-button
v-if="isOnlineDoc(row)"
link
type="primary"
@click="openDocEditor(row)"
>编辑</el-button
>
<el-button link type="primary" @click="handleDownload(row)">下载</el-button>
<el-button link type="primary" @click="handleStar(row)">{{
row.is_star === 1 ? "取消收藏" : "收藏"
@@ -277,7 +328,9 @@
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无文档" /></template>
<template #empty>
<el-empty description="暂无文档" />
</template>
</el-table>
<div class="pagination">
@@ -296,8 +349,10 @@
<!-- 上传 -->
<UploadDialog
v-model="uploadVisible"
:scope="activeScope"
:categories="categoryTree"
:default-category-id="activeCategoryId > 0 ? activeCategoryId : 0"
:default-visibility="activeScope === 'personal' ? 1 : 0"
:current-user-name="currentUserName"
@saved="onDataChanged"
/>
@@ -307,6 +362,18 @@
v-model="editVisible"
:doc="editingDoc"
:categories="categoryTree"
:scope="activeScope"
@saved="onDataChanged"
/>
<!-- 在线制作 / 编辑文档 -->
<DocEditorDialog
v-model="editorVisible"
:doc="editorDoc"
:scope="activeScope"
:categories="categoryTree"
:default-category-id="activeCategoryId > 0 ? activeCategoryId : 0"
:current-user-name="currentUserName"
@saved="onDataChanged"
/>
@@ -332,7 +399,7 @@
node-key="id"
check-strictly
clearable
placeholder="未分类"
placeholder="请选择目标分类"
style="width: 100%"
/>
<template #footer>
@@ -341,29 +408,17 @@
</template>
</el-dialog>
<!-- 图片预览 -->
<el-dialog v-model="previewVisible" :title="previewRow?.title" width="70%">
<div class="preview-body">
<el-image
v-if="previewRow && isImageDoc(previewRow)"
:src="getFileUrl(previewRow.file_url)"
fit="contain"
:preview-src-list="[getFileUrl(previewRow.file_url)]"
class="preview-image"
/>
<iframe
v-else-if="previewRow"
:src="getFileUrl(previewRow.file_url)"
class="preview-frame"
frameborder="0"
/>
</div>
</el-dialog>
<!-- 文档在线预览 -->
<DocPreviewDialog
v-model="previewVisible"
:doc="previewRow"
@download="handleDownload"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Delete,
@@ -372,6 +427,7 @@ import {
Film,
Files,
FolderOpened,
Lock,
Microphone,
Picture,
Plus,
@@ -386,12 +442,15 @@ import { useAuthStore } from "@/stores/auth";
import UploadDialog from "./components/uploadDialog.vue";
import EditDialog from "./components/editDialog.vue";
import DocDetailDrawer from "./components/docDetailDrawer.vue";
import DocPreviewDialog from "./components/docPreviewDialog.vue";
import DocEditorDialog from "./components/docEditorDialog.vue";
import {
batchDeleteDocs,
createDocCategory,
deleteDoc,
deleteDocCategory,
getDocCategories,
getDocDetail,
getDocList,
getDocStats,
moveDocs,
@@ -410,7 +469,6 @@ import {
formatDateTime,
formatFileSize,
getFileUrl,
isImageDoc,
parseTags
} from "../utils";
@@ -422,8 +480,13 @@ const categoryKeyword = ref("");
const treeRef = ref(null);
const expandedKeys = ref([]);
// 当前所在文档空间:shared=共享文档,personal=私密文档(各自独立的分类树)
const activeScope = ref("shared");
const activeCategoryId = ref(0);
const uncategorized = ref(false);
// 我创建的(在当前空间内进一步筛选)
const mineFilter = ref(false);
const filters = reactive({
keyword: "",
@@ -442,6 +505,8 @@ const stats = ref({});
const uploadVisible = ref(false);
const editVisible = ref(false);
const editingDoc = ref(null);
const editorVisible = ref(false);
const editorDoc = ref(null);
const detailVisible = ref(false);
const detailId = ref(0);
const moveVisible = ref(false);
@@ -450,16 +515,19 @@ const moving = ref(false);
const previewVisible = ref(false);
const previewRow = ref(null);
const uncategorizedCount = computed(() => stats.value?.uncategorized || 0);
const currentCategoryName = computed(() => {
if (uncategorized.value) return "未分类";
if (!activeCategoryId.value) return "全部文档";
return findCategory(categoryTree.value, activeCategoryId.value)?.name || "全部文档";
});
// 系统内置分类:由后端按租户自动补建(is_system=1),租户隔离且不允许删除。
function isSystemCategory(data) {
return Number(data?.is_system) === 1;
}
function categoryName(id) {
if (!id) return "未分类";
return findCategory(categoryTree.value, id)?.name || "未分类";
if (!id) return "-";
return findCategory(categoryTree.value, id)?.name || "-";
}
function typeIcon(docType) {
@@ -479,7 +547,7 @@ function typeIcon(docType) {
// ---------------- 数据加载 ----------------
async function loadCategories() {
const res = await getDocCategories();
const res = await getDocCategories({ scope: activeScope.value });
if (res?.code === 200) {
categoryTree.value = res.data || [];
expandedKeys.value = (res.data || []).map(c => c.id);
@@ -487,7 +555,7 @@ async function loadCategories() {
}
async function loadStats() {
const res = await getDocStats();
const res = await getDocStats({ scope: activeScope.value });
if (res?.code === 200) {
stats.value = res.data || {};
}
@@ -497,6 +565,7 @@ async function loadList() {
loading.value = true;
try {
const res = await getDocList({
scope: activeScope.value,
page: pagination.page,
pageSize: pagination.pageSize,
keyword: filters.keyword || "",
@@ -504,8 +573,8 @@ async function loadList() {
doc_type: filters.doc_type === "" ? -1 : filters.doc_type,
tag: filters.tag || "",
star: filters.star ? 1 : -1,
category_id: uncategorized.value ? 0 : activeCategoryId.value,
uncategorized: uncategorized.value ? 1 : 0
category_id: activeCategoryId.value,
mine: mineFilter.value ? 1 : 0
});
if (res?.code === 200) {
list.value = res.data?.list || [];
@@ -524,6 +593,16 @@ function loadData() {
loadCategories();
}
// 切换空间(共享 / 私密):重置选中并重新加载该空间的数据
function switchScope(scope) {
if (activeScope.value === scope) return;
activeScope.value = scope;
activeCategoryId.value = 0;
mineFilter.value = false;
pagination.page = 1;
loadData();
}
function onDataChanged() {
loadList();
loadStats();
@@ -538,16 +617,21 @@ function filterCategory(value, data) {
watch(categoryKeyword, val => treeRef.value?.filter(val));
// 快捷入口与分类树共用同一选中态,切换时同步树的高亮
watch([activeCategoryId, categoryTree], () => {
nextTick(() => {
treeRef.value?.setCurrentKey(activeCategoryId.value > 0 ? activeCategoryId.value : null);
});
});
function handleNodeClick(data) {
uncategorized.value = false;
activeCategoryId.value = data.id;
pagination.page = 1;
loadList();
}
function selectQuick(id) {
uncategorized.value = id === -1;
activeCategoryId.value = id === -1 ? 0 : 0;
activeCategoryId.value = id > 0 ? id : 0;
pagination.page = 1;
loadList();
}
@@ -561,6 +645,7 @@ async function openCreateCategory(parentId = 0) {
inputErrorMessage: "分类名称不能为空"
});
const res = await createDocCategory({
scope: activeScope.value,
parent_id: parentId || 0,
name: String(value || "").trim(),
sort: 0
@@ -601,6 +686,10 @@ async function openRenameCategory(data) {
}
async function handleDeleteCategory(data) {
if (isSystemCategory(data)) {
ElMessage.warning("系统内置分类不可删除");
return;
}
try {
await ElMessageBox.confirm(
`确定删除分类「${data.name}」吗?`,
@@ -674,13 +763,33 @@ async function handleStar(row) {
}
}
/** 在线制作的文档(可再次编辑):后端标记 is_online,兼容早期以 html 文件保存的文档 */
function isOnlineDoc(row) {
if (Number(row?.is_online) === 1) return true;
return ["html", "htm"].includes(String(row?.ext || "").toLowerCase());
}
/** 打开在线文档编辑器:传 row 为编辑已有文档,不传为新建 */
function openDocEditor(row) {
editorDoc.value = row || null;
editorVisible.value = true;
}
function handlePreview(row) {
if (!row?.file_url) {
ElMessage.warning("该文档没有附件,无法预览");
return;
}
previewRow.value = row;
previewVisible.value = true;
if (!isImageDoc(row) && !String(row.ext).toLowerCase().includes("pdf")) {
window.open(getFileUrl(row.file_url), "_blank");
previewVisible.value = false;
}
// 预览即视为"查看",用详情接口累加查看次数(失败不影响预览)
getDocDetail(row.id)
.then(res => {
if (res?.code === 200 && res.data) {
row.view_count = res.data.view_count;
}
})
.catch(() => {});
}
async function handleDownload(row) {
@@ -810,6 +919,49 @@ onMounted(() => {
margin-bottom: 14px;
}
.scope-tabs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.scope-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: 1px solid #dcdfe6;
border-radius: 8px;
background: #fff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
.el-icon {
font-size: 16px;
}
&:hover {
border-color: #c6e2ff;
color: #409eff;
}
&.active {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
font-weight: 600;
}
}
.scope-hint {
margin-left: 4px;
font-size: 12px;
color: #909399;
}
.stat-card {
background: #fff;
border: 1px solid #ebeef5;
@@ -896,11 +1048,29 @@ onMounted(() => {
color: #909399;
}
// 系统内置分类标记(项目文档)
.builtin-tag {
flex: 0 0 auto;
height: 18px;
padding: 0 4px;
line-height: 16px;
}
.tree-scroll {
max-height: 560px;
margin-top: 6px;
}
.tree-empty {
margin-top: 6px;
padding: 16px 8px;
border: 1px dashed var(--el-border-color, #dcdfe6);
border-radius: 6px;
color: #909399;
font-size: 12px;
text-align: center;
}
.tree-node {
display: flex;
align-items: center;
@@ -938,6 +1108,58 @@ onMounted(() => {
padding: 16px;
}
.visibility-chips {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.chips-label {
font-size: 13px;
color: #909399;
margin-right: 2px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid #dcdfe6;
border-radius: 16px;
background: #fff;
color: #606266;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.chip:hover {
border-color: #c6e2ff;
color: #409eff;
}
.chip.active {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
font-weight: 600;
}
.chip-count {
font-size: 12px;
padding: 0 6px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.06);
color: inherit;
}
.chip.active .chip-count {
background: rgba(64, 158, 255, 0.18);
}
.filter-bar {
display: flex;
align-items: center;
@@ -954,6 +1176,9 @@ onMounted(() => {
}
.toolbar-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
}
@@ -1011,24 +1236,6 @@ onMounted(() => {
margin-top: 14px;
}
.preview-body {
display: flex;
justify-content: center;
align-items: center;
min-height: 420px;
}
.preview-image {
max-width: 100%;
max-height: 60vh;
}
.preview-frame {
width: 100%;
height: 60vh;
border: none;
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
@@ -13,6 +13,7 @@
<script setup>
import { ref, watch } from 'vue'
import { UmoEditor } from '@umoteam/editor'
import '@umoteam/editor/style'
import { uploadFile } from '@/api/file'
const props = defineProps({
+73 -35
View File
@@ -69,6 +69,9 @@ func (c *BackendOaDocumentController) oaDocFail(err error) {
status, code, msg = 404, 404, err.Error()
case services.ErrDocLinkDuplicate:
status, code, msg = 409, 409, err.Error()
case services.ErrDocCategorySystem, services.ErrDocCategorySystemLocked,
services.ErrDocCategoryNameKept:
status, code, msg = 403, 403, err.Error()
default:
msg = err.Error()
}
@@ -95,14 +98,15 @@ func (c *BackendOaDocumentController) oaDocActor(claims *jwtutil.Claims) service
// ---------------- 分类 ----------------
// Categories GET /backend/oa/document/categories 分类树(含文档数量)
// Categories GET /backend/oa/document/categories?scope=shared|personal 文档分类树(含各分类文档数量)
func (c *BackendOaDocumentController) Categories() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims))
scope := strings.TrimSpace(c.GetString("scope"))
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims), scope)
if err != nil {
c.oaDocFail(err)
return
@@ -118,6 +122,7 @@ func (c *BackendOaDocumentController) CreateCategory() {
return
}
var body struct {
Scope string `json:"scope"`
ParentID uint64 `json:"parent_id"`
Name string `json:"name"`
Remark string `json:"remark"`
@@ -126,7 +131,8 @@ func (c *BackendOaDocumentController) CreateCategory() {
if !c.oaDocParseBody(&body) {
return
}
item, err := services.OaDocCategoryCreate(claims.TenantId, body.ParentID, body.Name, body.Remark, body.Sort)
item, err := services.OaDocCategoryCreate(
c.oaDocActor(claims), body.Scope, body.ParentID, body.Name, body.Remark, body.Sort)
if err != nil {
c.oaDocFail(err)
return
@@ -169,7 +175,7 @@ func (c *BackendOaDocumentController) UpdateCategory() {
if body.Sort != nil {
sort = *body.Sort
}
item, err := services.OaDocCategoryUpdate(claims.TenantId, id, name, remark, sort, parentID)
item, err := services.OaDocCategoryUpdate(c.oaDocActor(claims), id, name, remark, sort, parentID)
if err != nil {
c.oaDocFail(err)
return
@@ -188,7 +194,7 @@ func (c *BackendOaDocumentController) DeleteCategory() {
if !ok {
return
}
if err := services.OaDocCategoryDelete(claims.TenantId, id); err != nil {
if err := services.OaDocCategoryDelete(c.oaDocActor(claims), id); err != nil {
c.oaDocFail(err)
return
}
@@ -211,13 +217,19 @@ func (c *BackendOaDocumentController) List() {
docType, _ := c.GetInt("doc_type", -1)
star, _ := c.GetInt("star", -1)
categoryID, _ := c.GetUint64("category_id")
visibility, _ := c.GetInt("visibility", -1)
mine := strings.TrimSpace(c.GetString("mine")) == "1"
scope := strings.TrimSpace(c.GetString("scope"))
actor := c.oaDocActor(claims)
list, total, err := services.OaDocList(actor, services.OaDocListParams{
Actor: actor,
Scope: scope,
Keyword: strings.TrimSpace(c.GetString("keyword")),
CategoryID: categoryID,
Uncategorized: strings.TrimSpace(c.GetString("uncategorized")) == "1",
Visibility: visibility,
Mine: mine,
Status: status,
DocType: docType,
Tag: strings.TrimSpace(c.GetString("tag")),
@@ -232,14 +244,15 @@ func (c *BackendOaDocumentController) List() {
c.oaDocOk(map[string]interface{}{"list": list, "total": total})
}
// Stats GET /backend/oa/document/stats 文档库概览
// Stats GET /backend/oa/document/stats?scope=shared|personal|all 文档库概览
func (c *BackendOaDocumentController) Stats() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
stats, err := services.OaDocStats(c.oaDocActor(claims))
scope := strings.TrimSpace(c.GetString("scope"))
stats, err := services.OaDocStats(c.oaDocActor(claims), scope)
if err != nil {
c.oaDocFail(err)
return
@@ -266,6 +279,25 @@ func (c *BackendOaDocumentController) Detail() {
c.oaDocOk(item)
}
// Content GET /backend/oa/document/content/:id 读取在线文档的编辑源(不累加查看次数)
func (c *BackendOaDocumentController) Content() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
id, ok := c.oaDocID()
if !ok {
return
}
item, err := services.OaDocGet(c.oaDocActor(claims), id)
if err != nil {
c.oaDocFail(err)
return
}
c.oaDocOk(map[string]interface{}{"content": item.Content})
}
// Create POST /backend/oa/document/create
func (c *BackendOaDocumentController) Create() {
claims, err := c.oaDocClaims()
@@ -406,10 +438,10 @@ func (c *BackendOaDocumentController) Download() {
return
}
c.oaDocOk(map[string]interface{}{
"id": item.ID,
"title": item.Title,
"file_url": item.FileURL,
"file_name": item.FileName,
"id": item.ID,
"title": item.Title,
"file_url": item.FileURL,
"file_name": item.FileName,
"download_count": item.DownCount,
})
}
@@ -605,38 +637,44 @@ func (c *BackendOaDocumentController) oaDocParseSaveBody() (services.OaDocSavePa
var body struct {
CategoryID *uint64 `json:"category_id"`
Title string `json:"title"`
FileID uint64 `json:"file_id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
Ext string `json:"ext"`
Size uint64 `json:"size"`
Tags string `json:"tags"`
Summary string `json:"summary"`
Status *int8 `json:"status"`
Version int `json:"version"`
OwnerID uint64 `json:"owner_id"`
OwnerName string `json:"owner_name"`
IsStar *int8 `json:"is_star"`
Visibility *int8 `json:"visibility"`
FileID uint64 `json:"file_id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
Ext string `json:"ext"`
Size uint64 `json:"size"`
Tags string `json:"tags"`
Summary string `json:"summary"`
Content *string `json:"content"`
Status *int8 `json:"status"`
Version int `json:"version"`
OwnerID uint64 `json:"owner_id"`
OwnerName string `json:"owner_name"`
IsStar *int8 `json:"is_star"`
Visibility *int8 `json:"visibility"`
}
if !c.oaDocParseBody(&body) {
return services.OaDocSaveParams{}, false
}
params := services.OaDocSaveParams{
Title: body.Title,
FileID: body.FileID,
FileURL: body.FileURL,
FileName: body.FileName,
Ext: body.Ext,
Size: body.Size,
Tags: body.Tags,
Summary: body.Summary,
Version: body.Version,
OwnerID: body.OwnerID,
OwnerName: body.OwnerName,
Title: body.Title,
FileID: body.FileID,
FileURL: body.FileURL,
FileName: body.FileName,
Ext: body.Ext,
Size: body.Size,
Tags: body.Tags,
Summary: body.Summary,
Version: body.Version,
OwnerID: body.OwnerID,
OwnerName: body.OwnerName,
}
if body.Content != nil {
params.SetContent = true
params.Content = *body.Content
}
if body.Visibility != nil {
params.Visibility = *body.Visibility
params.SetVisibility = true
}
if body.CategoryID != nil {
params.SetCategory = true
+23 -1
View File
@@ -18,18 +18,40 @@
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户:共享空间共享=0(租户级);私密空间=创建者的 yz_system_tenant_user.uid',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享文档空间 personal-私密文档空间(绑定用户级别)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id)
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表';
-- 1.1 历史库补齐列(特性上线前已建表时执行,已存在则报错可忽略)
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享 personal-私密';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户(私密空间=租户用户 uid)';
-- 1.2 分类归属规则(双空间模型,2026-09 起):
-- 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树:
-- - 共享空间:scope=shared、user_id=0(租户级,全租户共用一套,任何人可建/删,内置分类除外);
-- - 私密空间:scope=personal、user_id=当前用户的 yz_system_tenant_user.uid(绑定到用户级别,各用户独立一套)。
-- 文档可见性(OaDoc.visibility)与所在空间一一对应:共享空间→0(租户公开),私密空间→1(私密,仅创建者与被共享者可见)。
-- 系统内置分类「项目文档」(scope=shared, user_id=0, is_system=1) 属于共享空间,按租户自动补建
-- (models.EnsureOaDocDefaultCategories),一般无需手工插入。
-- 历史脏数据(scope 不在 shared/personal)在 EnsureOaDocumentTables 中自动归为共享空间(user_id=0, scope=shared)。
-- 说明:「未分类」是虚拟分类,文档 category_id = 0 即表示未分类,两个空间各自独立计算,不落库、不可删除。
-- 2. 文档表
-- doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
-- status : 0-草稿 1-已发布 2-已归档
+204 -44
View File
@@ -4,6 +4,8 @@ import (
"strings"
"sync"
"time"
"github.com/beego/beego/v2/client/orm"
)
// OA 文档管理(文档库 / 文档图谱)数据模型。
@@ -14,14 +16,28 @@ import (
// - yz_backend_oa_doc_link 文档关联关系(引用/相关/版本/从属),文档图谱的连线来源
// OaDocCategory OA文档分类: yz_backend_oa_doc_category
//
// 双空间模型:文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自有一棵分类树。
// - 共享文档空间(Scope="shared"、UserID=0):租户级,租户内所有成员共用同一套文件夹结构;
// - 私密文档空间(Scope="personal"、UserID=登录用户的 yz_system_tenant_user.uid):
// 绑定到租户的用户级别,每位用户拥有自己独立的一套文件夹,互不干扰;
// - 文档的可见性(OaDoc.Visibility)与所在空间一一对应:共享空间→租户公开(0),私密空间→私密(1);
// - 系统内置的「项目文档」属于共享空间(Scope="shared"、UserID=0);
// - 「未分类」是虚拟分类(文档 category_id=0),不落库、不可删除,两个空间各自独立计算。
type OaDocCategory struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Name string `orm:"column(name);size(128)" json:"name"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
// UserID 分类归属用户:共享空间恒为 0(租户级);私密空间为创建者的 yz_system_tenant_user.uid。
UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Name string `orm:"column(name);size(128)" json:"name"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
IsSystem int8 `orm:"column(is_system);default(0)" json:"is_system"`
// Scope 所属空间:shared=共享文档空间,personal=私密文档空间(绑定到用户级别)。
Scope string `orm:"column(scope);size(16);default(shared)" json:"scope"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
@@ -35,33 +51,38 @@ func (m *OaDocCategory) TableName() string {
// status: 0-草稿 1-已发布 2-已归档
// doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
type OaDoc struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
CategoryID uint64 `orm:"column(category_id);default(0)" json:"category_id"`
Title string `orm:"column(title);size(255)" json:"title"`
FileID uint64 `orm:"column(file_id);default(0)" json:"file_id"`
FileURL string `orm:"column(file_url);size(512);default()" json:"file_url"`
FileName string `orm:"column(file_name);size(255);default()" json:"file_name"`
Ext string `orm:"column(ext);size(16);default()" json:"ext"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
DocType int8 `orm:"column(doc_type);default(0)" json:"doc_type"`
Tags string `orm:"column(tags);size(255);default()" json:"tags"`
Summary string `orm:"column(summary);size(1000);default()" json:"summary"`
Status int8 `orm:"column(status);default(0)" json:"status"`
Version int `orm:"column(version);default(1)" json:"version"`
IsStar int8 `orm:"column(is_star);default(0)" json:"is_star"`
OwnerID uint64 `orm:"column(owner_id);default(0)" json:"owner_id"`
OwnerName string `orm:"column(owner_name);size(100);default()" json:"owner_name"`
ViewCount int `orm:"column(view_count);default(0)" json:"view_count"`
DownCount int `orm:"column(download_count);default(0)" json:"download_count"`
CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"`
CreatorName string `orm:"column(creator_name);size(100);default()" json:"creator_name"`
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
CategoryID uint64 `orm:"column(category_id);default(0)" json:"category_id"`
Title string `orm:"column(title);size(255)" json:"title"`
FileID uint64 `orm:"column(file_id);default(0)" json:"file_id"`
FileURL string `orm:"column(file_url);size(512);default()" json:"file_url"`
FileName string `orm:"column(file_name);size(255);default()" json:"file_name"`
Ext string `orm:"column(ext);size(16);default()" json:"ext"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
DocType int8 `orm:"column(doc_type);default(0)" json:"doc_type"`
Tags string `orm:"column(tags);size(255);default()" json:"tags"`
Summary string `orm:"column(summary);size(1000);default()" json:"summary"`
// Content 在线制作文档的编辑源(富文本 HTML),仅用于再次编辑;上传文件或普通文档为空。
// 注意:文档对外的写入/下载格式为 docx(由该源导出),此字段只是编辑器的中间态。
Content string `orm:"column(content);type(longtext);null" json:"content,omitempty"`
// IsOnline 是否在线制作(非持久化字段,列表接口按 Content 是否为空计算)
IsOnline int8 `orm:"-" json:"is_online"`
Status int8 `orm:"column(status);default(0)" json:"status"`
Version int `orm:"column(version);default(1)" json:"version"`
IsStar int8 `orm:"column(is_star);default(0)" json:"is_star"`
OwnerID uint64 `orm:"column(owner_id);default(0)" json:"owner_id"`
OwnerName string `orm:"column(owner_name);size(100);default()" json:"owner_name"`
ViewCount int `orm:"column(view_count);default(0)" json:"view_count"`
DownCount int `orm:"column(download_count);default(0)" json:"download_count"`
CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"`
CreatorName string `orm:"column(creator_name);size(100);default()" json:"creator_name"`
// Visibility 可见性:0-租户公开(默认,租户内所有后台用户可见)1-私密(仅创建者与被共享者可见)
Visibility int8 `orm:"column(visibility);default(0)" json:"visibility"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
Visibility int8 `orm:"column(visibility);default(0)" json:"visibility"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaDoc) TableName() string {
@@ -88,7 +109,8 @@ func (m *OaDocLink) TableName() string {
// OaDocShare OA文档共享: yz_backend_oa_doc_share
// 私密文档(visibility=1)在"创建者可见"之外,额外授权给指定用户或部门。
// share_type: 0-用户(target_id 为 yz_system_tenant_user.uid)
// 1-部门(target_id 为 yz_backend_organization.id,含其全部子部门)
//
// 1-部门(target_id 为 yz_backend_organization.id,含其全部子部门)
type OaDocShare struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
@@ -103,17 +125,39 @@ func (m *OaDocShare) TableName() string {
return "yz_backend_oa_doc_share"
}
// 文档分类内置标记常量(is_system)
const (
DocCategoryNormal int8 = 0 // 普通分类,租户可自行删除
DocCategorySystem int8 = 1 // 系统内置分类,任何租户都不可删除
)
// 文档范围(scope):文档库按「共享文档 / 私密文档」两个空间组织,分类同样按此隔离。
// 共享空间(shared):租户级,所有人共用;私密空间(personal):绑定到用户级别,每人独立一套。
// 文档侧复用 visibility 字段:personal 对应私密(1),shared 对应租户公开(0)。
const (
DocScopeAll = "all"
DocScopePersonal = "personal"
DocScopeShared = "shared"
)
// DocCategoryNameProject 系统内置分类名称:项目文档,只属于共享文档范围。
// 每个租户首次访问文档接口时自动补建自己那一份(按 tid 隔离),租户之间互不可见。
const DocCategoryNameProject = "项目文档"
// docCategorySystemRemark 内置分类的默认备注
const docCategorySystemRemark = "系统内置分类,不可删除"
// 文档类型常量(doc_type)
const (
DocTypeOther int8 = 0
DocTypeDoc int8 = 1 // doc / docx / txt / md / rtf
DocTypeSheet int8 = 2 // xls / xlsx / csv
DocTypeSlide int8 = 3 // ppt / pptx
DocTypePDF int8 = 4 // pdf
DocTypeImage int8 = 5 // jpg / png / gif / bmp / webp
DocTypeZip int8 = 6 // zip / rar / 7z / tar / gz
DocTypeVideo int8 = 7
DocTypeAudio int8 = 8
DocTypeOther int8 = 0
DocTypeDoc int8 = 1 // doc / docx / txt / md / rtf
DocTypeSheet int8 = 2 // xls / xlsx / csv
DocTypeSlide int8 = 3 // ppt / pptx
DocTypePDF int8 = 4 // pdf
DocTypeImage int8 = 5 // jpg / png / gif / bmp / webp
DocTypeZip int8 = 6 // zip / rar / 7z / tar / gz
DocTypeVideo int8 = 7
DocTypeAudio int8 = 8
)
// 文档状态常量(status)
@@ -179,16 +223,20 @@ func EnsureOaDocumentTables() error {
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID 0-租户级(共享分类) >0-该用户的个人分类',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属范围 personal-个人文档 shared-共享文档',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id)
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表'`).Exec()
if err != nil {
return
@@ -208,6 +256,7 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc (
doc_type tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频',
tags varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
summary varchar(1000) NOT NULL DEFAULT '' COMMENT '摘要/描述',
content longtext NULL COMMENT '在线制作文档的编辑源(富文本HTML),仅用于再次编辑',
status tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已归档',
version int NOT NULL DEFAULT 1 COMMENT '版本号',
is_star tinyint NOT NULL DEFAULT 0 COMMENT '是否收藏 0-否 1-是',
@@ -256,6 +305,30 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_link (
ADD COLUMN visibility tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)'
`).Exec()
// 历史库补齐在线文档编辑源列 content(在线制作功能上线前已建表)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc
ADD COLUMN content longtext NULL COMMENT '在线制作文档的编辑源(富文本HTML),仅用于再次编辑'
`).Exec()
// 历史库补齐分类表的 is_system 列(内置分类特性上线前已建表)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)'
`).Exec()
// 历史库补齐分类表的 scope 列:既有分类默认归入共享文档,保证升级后仍可见
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属范围 personal-个人文档 shared-共享文档'
`).Exec()
// 历史库补齐分类表的 user_id 列:既有分类默认归租户级(共享分类)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID 0-租户级(共享分类) >0-该用户的个人分类'
`).Exec()
_, err = Orm.Raw(`
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -270,6 +343,93 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
KEY idx_tid_doc (tid, doc_id),
KEY idx_target (tid, share_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档共享表'`).Exec()
if err != nil {
return
}
// 双空间模型:兼容存量数据,将不在 (shared, personal) 范围内的分类统一归为共享空间
// (user_id 置 0、scope 置 shared),避免历史脏数据导致分类不可见。
// 注意:不再把 personal 并入 shared,私密空间需保留各自的 user_id 隔离。
_, _ = Orm.Raw(`
UPDATE yz_backend_oa_doc_category
SET user_id = 0, scope = 'shared'
WHERE scope NOT IN ('shared', 'personal') AND is_deleted = 0
`).Exec()
})
return err
}
// oaDocDefaultCategoryOnce 记录已完成内置分类补建的租户,避免每次请求重复查库。
// key 为 tid(int),value 为 struct{}{};补建失败时不写缓存,下次请求继续重试。
var oaDocDefaultCategoryOnce sync.Map
// EnsureOaDocDefaultCategories 保证指定租户的系统内置分类齐全(当前为「项目文档」)。
//
// 租户隔离:内置分类是真实数据行,每个租户各自持有一份(tid 不同),
// 首次访问文档接口时自动补建,租户之间互不可见、互不影响。
//
// 范围隔离:「项目文档」属于共享文档空间(scope=shared、user_id=0),租户内所有人共用。
// 私密文档空间不预置内置分类,由用户自行建立自己的文件夹。
//
// 「未分类」不落库:它是虚拟分类,文档 category_id = 0 即表示未分类,因此天然不可删除;
// 同时在 services 层把「未分类」「项目文档」列为保留名,禁止新建同名分类与之混淆。
//
// 任何失败均静默忽略,不阻断主流程(与 EnsureDefaultTenantRoles 保持一致)。
func EnsureOaDocDefaultCategories(tid int) {
if Orm == nil || tid <= 0 {
return
}
if _, done := oaDocDefaultCategoryOnce.Load(tid); done {
return
}
if err := ensureOaDocProjectCategory(tid); err == nil {
oaDocDefaultCategoryOnce.Store(tid, struct{}{})
}
}
// ensureOaDocProjectCategory 补建租户的「项目文档」内置分类:
// 1. 已有任意内置分类(is_system=1):说明租户已初始化过(可能被改名),直接返回;
// 2. 已有同名普通分类:升级为内置分类,避免出现两个「项目文档」;
// 3. 都没有:插入一条一级内置分类。
func ensureOaDocProjectCategory(tid int) error {
// 「项目文档」为统一租户分类树下的内置分类(scope=shared)
qs := Orm.QueryTable(new(OaDocCategory)).
Filter("tid", tid).
Filter("scope", DocScopeShared)
sysCount, err := qs.Filter("is_system", DocCategorySystem).Count()
if err != nil {
return err
}
if sysCount > 0 {
return nil
}
var existed []OaDocCategory
if _, err := qs.Filter("name", DocCategoryNameProject).
Filter("is_deleted", 0).
OrderBy("id").Limit(1).All(&existed); err != nil && err != orm.ErrNoRows {
return err
}
if len(existed) > 0 {
item := existed[0]
item.IsSystem = DocCategorySystem
if item.Remark == "" {
item.Remark = docCategorySystemRemark
}
_, err := Orm.Update(&item, "IsSystem", "Remark")
return err
}
_, err = Orm.Insert(&OaDocCategory{
Tid: tid,
ParentID: 0,
Name: DocCategoryNameProject,
Sort: 0,
Remark: docCategorySystemRemark,
IsSystem: DocCategorySystem,
Scope: DocScopeShared,
IsDeleted: 0,
})
return err
}
+1
View File
@@ -306,6 +306,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/document/links", &controllers.BackendOaDocumentController{}, "get:LinkList;post:LinkCreate")
beego.Router("/backend/oa/document/links/:id", &controllers.BackendOaDocumentController{}, "delete:LinkDelete")
beego.Router("/backend/oa/document/detail/:id", &controllers.BackendOaDocumentController{}, "get:Detail")
beego.Router("/backend/oa/document/content/:id", &controllers.BackendOaDocumentController{}, "get:Content")
beego.Router("/backend/oa/document/create", &controllers.BackendOaDocumentController{}, "post:Create")
beego.Router("/backend/oa/document/update/:id", &controllers.BackendOaDocumentController{}, "post:Update")
beego.Router("/backend/oa/document/delete/:id", &controllers.BackendOaDocumentController{}, "delete:Delete")
+476 -129
View File
@@ -18,20 +18,23 @@ import (
// 业务错误
var (
ErrDocCategoryNotFound = errors.New("分类不存在")
ErrDocCategoryHasChild = errors.New("存在子分类,请先删除子分类")
ErrDocCategoryHasDoc = errors.New("分类下仍有文档,请先移动或删除")
ErrDocCategoryNameEmpty = errors.New("请输入分类名称")
ErrDocCategoryCircular = errors.New("不能将分类移动到自己或其子分类下")
ErrDocNotFound = errors.New("文档不存在")
ErrDocTitleEmpty = errors.New("请输入文档标题")
ErrDocCategoryInvalid = errors.New("所属分类不存在")
ErrDocLinkSelf = errors.New("不能关联文档自身")
ErrDocLinkDuplicate = errors.New("该关联关系已存在")
ErrDocLinkTargetNotFound = errors.New("被关联的文档不存在")
ErrDocLinkNotFound = errors.New("关联关系不存在")
ErrDocIDEmpty = errors.New("请选择要操作的文档")
ErrDocNoPermission = errors.New("无权操作该私密文档")
ErrDocCategoryNotFound = errors.New("分类不存在")
ErrDocCategoryHasChild = errors.New("存在子分类,请先删除子分类")
ErrDocCategoryHasDoc = errors.New("分类下仍有文档,请先移动或删除")
ErrDocCategoryNameEmpty = errors.New("请输入分类名称")
ErrDocCategoryCircular = errors.New("不能将分类移动到自己或其子分类下")
ErrDocCategorySystem = errors.New("系统内置分类不可删除")
ErrDocCategoryNameKept = errors.New("该分类名称为系统保留,不可使用")
ErrDocCategorySystemLocked = errors.New("系统内置分类固定为一级分类,不可移动")
ErrDocNotFound = errors.New("文档不存在")
ErrDocTitleEmpty = errors.New("请输入文档标题")
ErrDocCategoryInvalid = errors.New("所属分类不存在")
ErrDocLinkSelf = errors.New("不能关联文档自身")
ErrDocLinkDuplicate = errors.New("该关联关系已存在")
ErrDocLinkTargetNotFound = errors.New("被关联的文档不存在")
ErrDocLinkNotFound = errors.New("关联关系不存在")
ErrDocIDEmpty = errors.New("请选择要操作的文档")
ErrDocNoPermission = errors.New("无权操作该私密文档")
)
// ---------------- 访问者上下文与可见性 ----------------
@@ -54,6 +57,8 @@ type OaDocActor struct {
func NewOaDocActor(tid int, uid uint64) OaDocActor {
models.EnsureOaDocumentTables()
models.EnsureTenantUserOrgColumn()
// 补建租户自己的系统内置分类(项目文档),按 tid 隔离
models.EnsureOaDocDefaultCategories(tid)
actor := OaDocActor{Tid: tid, UID: uid, OrgIDs: []uint64{}}
@@ -164,6 +169,48 @@ func oaDocApplyVisible(qs orm.QuerySeter, a OaDocActor) orm.QuerySeter {
return qs.SetCond(qs.GetCond().AndCond(cond))
}
// ---------------- 可见性(共享 / 私密) ----------------
//
// 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树;
// 文档可见性由 visibility 决定,并与所在空间一一对应:0=租户公开(共享空间),1=私密(私密空间,仅创建者及被共享者可见)。
// "我的文档"由 creator_id 决定,不与空间字段强耦合。
// oaDocVisibilityRestrictSQL 生成 Raw SQL 场景复用的「用户级可见性」条件片段
// (非管理员才附加:公开 OR 本人创建 OR 已共享给我)。prefix 为表别名前缀。
func oaDocVisibilityRestrictSQL(a OaDocActor, prefix string) (string, []interface{}) {
if a.IsAdmin {
return "", nil
}
shared := oaDocSharedDocIDs(a)
if len(shared) == 0 {
shared = []uint64{0} // 避免 SQL 出现 IN ()
}
placeholders := make([]string, len(shared))
for i := range shared {
placeholders[i] = "?"
}
args := []interface{}{a.UID}
for _, id := range shared {
args = append(args, id)
}
return fmt.Sprintf(
" AND (%svisibility = 0 OR %screator_id = ? OR %sid IN (%s))",
prefix, prefix, prefix, strings.Join(placeholders, ",")), args
}
// oaDocApplyVisibility 给查询集叠加绝对可见性过滤(按文档自身的 visibility 字段)。
// v<0 不过滤;0=公开;1=私密。
func oaDocApplyVisibility(qs orm.QuerySeter, v int) orm.QuerySeter {
switch v {
case 0:
return qs.Filter("visibility", models.DocVisibilityPublic)
case 1:
return qs.Filter("visibility", models.DocVisibilityPrivate)
default:
return qs
}
}
// oaDocVisibleIDSet 从给定文档 ID 中筛出当前访问者可见的部分。
func oaDocVisibleIDSet(a OaDocActor, ids []uint64) map[uint64]bool {
out := make(map[uint64]bool, len(ids))
@@ -217,24 +264,114 @@ type OaDocCategoryItem struct {
Children []*OaDocCategoryItem `json:"children"`
}
// oaDocCategoryBase 租户内未删除分类的基础查询集
func oaDocCategoryBase(tid int) orm.QuerySeter {
// ---------------- 范围(共享 / 私密) ----------------
// oaDocScopeOf 归一化范围参数:空或未知一律视为共享文档空间。
func oaDocScopeOf(scope string) string {
if scope == models.DocScopePersonal {
return models.DocScopePersonal
}
if scope == models.DocScopeAll {
return models.DocScopeAll
}
return models.DocScopeShared
}
// oaDocScopeVisibility 返回某范围对应的文档可见性:私密→私密(1),其余→租户公开(0)。
func oaDocScopeVisibility(scope string) int8 {
if scope == models.DocScopePersonal {
return models.DocVisibilityPrivate
}
return models.DocVisibilityPublic
}
// oaDocScopeOfVisibility 由文档可见性反推范围:私密(1)→私密文档空间,其余→共享文档空间。
func oaDocScopeOfVisibility(v int8) string {
if v == models.DocVisibilityPrivate {
return models.DocScopePersonal
}
return models.DocScopeShared
}
// oaDocCategoryUserID 返回某范围下分类的归属用户:共享→0(租户级),私密→当前用户(绑定到用户级别)。
func oaDocCategoryUserID(a OaDocActor, scope string) uint64 {
if scope == models.DocScopePersonal {
return a.UID
}
return 0
}
// oaDocCategoryBaseAny 租户内未删除分类的基础查询集(不限范围,用于按 ID 检索 / 权限校验)
func oaDocCategoryBaseAny(tid int) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaDocCategory)).
Filter("tid", tid).
Filter("is_deleted", 0)
}
// OaDocCategoryTree 返回分类树,每个节点附带该分类下的文档数量(仅统计直属文档)。
// 数量统计同样受可见性约束:无权限的私密文档不计入。
func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
// oaDocCategoryBase 返回指定范围内的未删除分类基础查询集。
// 共享空间:scope=shared 且 user_id=0(租户级,所有人共用);
// 私密空间:scope=personal 且 user_id=当前用户(绑定到用户级别,互不干扰);
// all:跨两个空间(仅图谱等全局视图使用)。
func oaDocCategoryBase(a OaDocActor, scope string) orm.QuerySeter {
s := oaDocScopeOf(scope)
if s == models.DocScopeAll {
// 跨空间:不加 scope / user_id 过滤
return oaDocCategoryBaseAny(a.Tid)
}
qs := oaDocCategoryBaseAny(a.Tid).Filter("scope", s)
if s == models.DocScopePersonal {
qs = qs.Filter("user_id", a.UID)
} else if s == models.DocScopeShared {
qs = qs.Filter("user_id", 0)
}
return qs
}
// oaDocCategoryIDs 返回指定范围内全部未删除分类的 ID。
func oaDocCategoryIDs(a OaDocActor, scope string) ([]uint64, error) {
var list []models.OaDocCategory
qs := oaDocCategoryBaseAny(a.Tid)
if scope != models.DocScopeAll {
s := oaDocScopeOf(scope)
qs = qs.Filter("scope", s)
if s == models.DocScopePersonal {
qs = qs.Filter("user_id", a.UID)
} else if s == models.DocScopeShared {
qs = qs.Filter("user_id", 0)
}
}
if _, err := qs.All(&list); err != nil && err != orm.ErrNoRows {
return nil, err
}
ids := make([]uint64, 0, len(list))
for _, c := range list {
ids = append(ids, c.ID)
}
return ids, nil
}
// oaDocApplyUncategorized 筛选"未分类"文档:category_id=0 或不属于当前范围任何分类。
func oaDocApplyUncategorized(qs orm.QuerySeter, a OaDocActor, scope string) orm.QuerySeter {
ids, err := oaDocCategoryIDs(a, scope)
if err != nil || len(ids) == 0 {
return qs // 范围内没有任何分类,全部视为未分类
}
return qs.Exclude("category_id__in", ids)
}
// OaDocCategoryTree 返回指定范围内的文档分类树,每个节点附带该分类下的文档数量。
// scope="shared" 返回共享空间树(租户级),scope="personal" 返回当前用户的私密空间树。
// 系统内置分类(项目文档)置顶;数量统计受可见性约束:无权限的私密文档不计入。
func OaDocCategoryTree(a OaDocActor, scope string) ([]*OaDocCategoryItem, error) {
models.EnsureOaDocumentTables()
scope = oaDocScopeOf(scope)
var list []models.OaDocCategory
if _, err := oaDocCategoryBase(a.Tid).OrderBy("sort", "id").All(&list); err != nil && err != orm.ErrNoRows {
if _, err := oaDocCategoryBase(a, scope).OrderBy("-is_system", "sort", "id").All(&list); err != nil && err != orm.ErrNoRows {
return nil, err
}
counts, err := OaDocCountByCategory(a)
counts, err := OaDocCountByCategory(a, scope, -1)
if err != nil {
return nil, err
}
@@ -249,6 +386,7 @@ func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
}
nodes[item.ID] = item
}
// 父节点必须也在本范围内(私密空间的父节点不会是共享空间的分类),否则归入根
for i := range list {
item := nodes[list[i].ID]
if parent, ok := nodes[list[i].ParentID]; ok && list[i].ParentID != 0 {
@@ -257,33 +395,44 @@ func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
roots = append(roots, item)
}
}
// 汇总子孙分类的文档数到父级:父级展示的是"包含所有子分类"的文档总数,
// 避免出现父级有子分类却显示 0 的情况。
var rollup func(item *OaDocCategoryItem) int64
rollup = func(item *OaDocCategoryItem) int64 {
total := item.DocCount
for _, child := range item.Children {
total += rollup(child)
}
item.DocCount = total
return total
}
for _, root := range roots {
rollup(root)
}
return roots, nil
}
// OaDocCountByCategory 统计各分类下未删除文档的数量(仅统计当前访问者可见的文档)。
func OaDocCountByCategory(a OaDocActor) (map[uint64]int64, error) {
// OaDocCountByCategory 统计指定范围内各分类下未删除文档的数量。
// visibility<0 时按范围自动取对应可见性(共享→公开,私密→私密);可见性约束同样生效:无权限的私密文档不计入。
// scope="all" 时跨两个空间统计(不限定 visibility)。
func OaDocCountByCategory(a OaDocActor, scope string, visibility int) (map[uint64]int64, error) {
models.EnsureOaDocumentTables()
scope = oaDocScopeOf(scope)
sql := "SELECT category_id, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0"
args := []interface{}{a.Tid}
if !a.IsAdmin {
shared := oaDocSharedDocIDs(a)
if len(shared) == 0 {
shared = []uint64{0}
if scope != models.DocScopeAll {
vis := int8(0)
if visibility >= 0 {
vis = int8(visibility)
} else {
vis = oaDocScopeVisibility(scope)
}
placeholders := make([]string, len(shared))
for i := range shared {
placeholders[i] = "?"
}
// 占位符顺序需与 args 一致:先 creator_id,再 IN (...)
args = append(args, a.UID)
for _, id := range shared {
args = append(args, id)
}
sql += fmt.Sprintf(
" AND (visibility = 0 OR creator_id = ? OR id IN (%s))",
strings.Join(placeholders, ","))
sql += fmt.Sprintf(" AND visibility = %d", vis)
}
extra, extraArgs := oaDocVisibilityRestrictSQL(a, "")
sql += extra
args = append(args, extraArgs...)
sql += " GROUP BY category_id"
var rows []struct {
@@ -300,16 +449,30 @@ func OaDocCountByCategory(a OaDocActor) (map[uint64]int64, error) {
return result, nil
}
// OaDocCategoryCreate 新建分类。
func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int) (*models.OaDocCategory, error) {
// oaDocIsReservedName 判断分类名是否为系统保留名。
// 「未分类」是虚拟分类(文档 category_id=0),不允许新建同名真实分类;
// 「项目文档」为系统内置分类名(共享空间),禁止重复创建。
func oaDocIsReservedName(name string) bool {
name = strings.TrimSpace(name)
return name == "未分类" || name == models.DocCategoryNameProject
}
// OaDocCategoryCreate 在指定空间下新建分类。
// scope="shared" 创建到共享空间(user_id=0,租户级);scope="personal" 创建到当前用户的私密空间(user_id=uid)。
// 父级分类必须与目标空间一致,否则视为不存在。
func OaDocCategoryCreate(a OaDocActor, scope string, parentID uint64, name, remark string, sort int) (*models.OaDocCategory, error) {
models.EnsureOaDocumentTables()
scope = oaDocScopeOf(scope)
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrDocCategoryNameEmpty
}
if oaDocIsReservedName(name) {
return nil, ErrDocCategoryNameKept
}
if parentID != 0 {
ok, err := oaDocCategoryExists(tid, parentID)
ok, err := oaDocCategoryExists(a, scope, parentID)
if err != nil {
return nil, err
}
@@ -319,11 +482,13 @@ func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int
}
item := &models.OaDocCategory{
Tid: tid,
Tid: a.Tid,
UserID: oaDocCategoryUserID(a, scope),
ParentID: parentID,
Name: name,
Remark: strings.TrimSpace(remark),
Sort: sort,
Scope: scope,
IsDeleted: 0,
}
if _, err := models.Orm.Insert(item); err != nil {
@@ -333,16 +498,22 @@ func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int
}
// OaDocCategoryUpdate 修改分类名称/备注/排序,parentID < 0 表示不调整父级。
func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) {
func OaDocCategoryUpdate(a OaDocActor, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) {
models.EnsureOaDocumentTables()
var item models.OaDocCategory
if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil {
if err := oaDocCategoryBaseAny(a.Tid).Filter("id", id).One(&item); err != nil {
return nil, ErrDocCategoryNotFound
}
if err := oaDocCheckCategoryOwner(a, &item); err != nil {
return nil, err
}
fields := []string{"UpdateTime"}
if name = strings.TrimSpace(name); name != "" && name != item.Name {
if item.IsSystem != models.DocCategorySystem && oaDocIsReservedName(name) {
return nil, ErrDocCategoryNameKept
}
item.Name = name
fields = append(fields, "Name")
}
@@ -355,20 +526,22 @@ func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, pare
fields = append(fields, "Sort")
}
if parentID >= 0 && uint64(parentID) != item.ParentID {
if item.IsSystem == models.DocCategorySystem {
return nil, ErrDocCategorySystemLocked
}
newParent := uint64(parentID)
if newParent == id {
return nil, ErrDocCategoryCircular
}
if newParent != 0 {
ok, err := oaDocCategoryExists(tid, newParent)
ok, err := oaDocCategoryExists(a, item.Scope, newParent)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrDocCategoryNotFound
}
// 禁止挂到自己的子孙节点下,避免形成环
childIDs, err := OaDocCategoryDescendants(tid, id)
childIDs, err := OaDocCategoryDescendants(a, newParent)
if err != nil {
return nil, err
}
@@ -394,9 +567,10 @@ func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, pare
}
// OaDocCategoryDescendants 收集某分类的全部子孙 ID(不含自身)。
func OaDocCategoryDescendants(tid int, rootID uint64) ([]uint64, error) {
// 遍历租户内全部未删除分类(跨空间),用于移动时的环路检测。
func OaDocCategoryDescendants(a OaDocActor, rootID uint64) ([]uint64, error) {
var list []models.OaDocCategory
if _, err := oaDocCategoryBase(tid).All(&list); err != nil && err != orm.ErrNoRows {
if _, err := oaDocCategoryBaseAny(a.Tid).All(&list); err != nil && err != orm.ErrNoRows {
return nil, err
}
children := make(map[uint64][]uint64)
@@ -420,27 +594,35 @@ func OaDocCategoryDescendants(tid int, rootID uint64) ([]uint64, error) {
}
// oaDocCategoryIDsWithChildren 返回指定分类及其所有子孙 ID;rootID=0 时返回 nil 表示不限制。
func oaDocCategoryIDsWithChildren(tid int, rootID uint64) ([]uint64, error) {
func oaDocCategoryIDsWithChildren(a OaDocActor, scope string, rootID uint64) ([]uint64, error) {
if rootID == 0 {
return nil, nil
}
children, err := OaDocCategoryDescendants(tid, rootID)
children, err := OaDocCategoryDescendants(a, rootID)
if err != nil {
return nil, err
}
return append([]uint64{rootID}, children...), nil
}
// OaDocCategoryDelete 删除分类:存在子分类或文档时拒绝删除。
func OaDocCategoryDelete(tid int, id uint64) error {
// OaDocCategoryDelete 删除分类:系统内置分类、存在子分类或文档时拒绝删除。
// 共享空间分类任何租户成员均可删除(内置分类除外);私密空间分类仅本人可删除。
func OaDocCategoryDelete(a OaDocActor, id uint64) error {
models.EnsureOaDocumentTables()
var item models.OaDocCategory
if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil {
if err := oaDocCategoryBaseAny(a.Tid).Filter("id", id).One(&item); err != nil {
return ErrDocCategoryNotFound
}
// 内置分类(如「项目文档」)为默认分类,任何租户都不可删除
if item.IsSystem == models.DocCategorySystem {
return ErrDocCategorySystem
}
if err := oaDocCheckCategoryOwner(a, &item); err != nil {
return err
}
childCount, err := oaDocCategoryBase(tid).Filter("parent_id", id).Count()
childCount, err := oaDocCategoryBase(a, item.Scope).Filter("parent_id", id).Count()
if err != nil {
return err
}
@@ -448,7 +630,7 @@ func OaDocCategoryDelete(tid int, id uint64) error {
return ErrDocCategoryHasChild
}
docCount, err := oaDocBase(tid).Filter("category_id", id).Count()
docCount, err := oaDocBase(a.Tid).Filter("category_id", id).Count()
if err != nil {
return err
}
@@ -457,7 +639,7 @@ func OaDocCategoryDelete(tid int, id uint64) error {
}
now := time.Now()
_, err = oaDocCategoryBase(tid).Filter("id", id).Update(orm.Params{
_, err = oaDocCategoryBaseAny(a.Tid).Filter("id", id).Update(orm.Params{
"IsDeleted": 1,
"DeleteTime": now,
"UpdateTime": now,
@@ -465,14 +647,37 @@ func OaDocCategoryDelete(tid int, id uint64) error {
return err
}
func oaDocCategoryExists(tid int, id uint64) (bool, error) {
n, err := oaDocCategoryBase(tid).Filter("id", id).Count()
// oaDocCategoryExists 判断指定范围内是否存在该分类(私密空间按 user_id 隔离)。
func oaDocCategoryExists(a OaDocActor, scope string, id uint64) (bool, error) {
n, err := oaDocCategoryBase(a, scope).Filter("id", id).Count()
if err != nil {
return false, err
}
return n > 0, nil
}
// oaDocCheckCategoryOwner 校验分类是否可被该访问者操作。
// 共享分类(user_id=0)租户内任何成员均可操作;私密分类(user_id>0)仅归属用户本人可操作。
func oaDocCheckCategoryOwner(a OaDocActor, item *models.OaDocCategory) error {
if item.UserID == 0 {
return nil
}
if item.UserID == a.UID && a.UID != 0 {
return nil
}
return ErrDocCategoryNotFound
}
// oaDocCategoryUsable 读取指定范围内的分类并校验当前访问者是否可以使用它(用于文档归类)。
// 不存在或不属于当前范围/用户时返回 ok=false。
func oaDocCategoryUsable(a OaDocActor, scope string, id uint64) (models.OaDocCategory, bool, error) {
var item models.OaDocCategory
if err := oaDocCategoryBase(a, scope).Filter("id", id).One(&item); err != nil {
return item, false, nil
}
return item, true, nil
}
// ---------------- 文档 ----------------
// oaDocBase 租户内未删除文档的基础查询集
@@ -483,13 +688,17 @@ func oaDocBase(tid int) orm.QuerySeter {
}
// OaDocListParams 文档列表查询条件。
// Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类。
// Actor 为访问者上下文,用于租户内用户级可见性过滤(私密文档只对创建者/被共享者可见)。
// Scope 限定空间:"shared"=共享文档(租户公开),"personal"=私密文档(绑定用户级别),"all"=跨两个空间。
// Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类(须与 Scope 同空间)。
// Mine 为 true 时只看本人创建的文档。Actor 用于租户内用户级可见性过滤。
type OaDocListParams struct {
Actor OaDocActor
Actor OaDocActor
Scope string
Keyword string
CategoryID uint64
Uncategorized bool // true 时只看未分类(category_id = 0)的文档
Visibility int // -1 不限(由 Scope 推导);0 公开;1 私密
Mine bool // true 时只看本人创建的文档
Status int
DocType int
Tag string
@@ -498,11 +707,18 @@ type OaDocListParams struct {
PageSize int
}
// OaDocList 分页查询文档(自动应用可见性过滤)。
// OaDocList 分页查询文档(自动应用可见性与范围过滤)。
func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) {
models.EnsureOaDocumentTables()
scope := oaDocScopeOf(p.Scope)
qs := oaDocApplyVisible(oaDocBase(a.Tid), a)
if scope != models.DocScopeAll {
qs = qs.Filter("visibility", oaDocScopeVisibility(scope))
}
if p.Mine {
qs = qs.Filter("creator_id", a.UID)
}
if kw := strings.TrimSpace(p.Keyword); kw != "" {
cond := orm.NewCondition()
cond = cond.Or("title__icontains", kw).
@@ -513,14 +729,17 @@ func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) {
qs = qs.SetCond(qs.GetCond().AndCond(cond))
}
if p.Uncategorized {
qs = qs.Filter("category_id", 0)
qs = oaDocApplyUncategorized(qs, a, scope)
} else if p.CategoryID > 0 {
ids, err := oaDocCategoryIDsWithChildren(a.Tid, p.CategoryID)
ids, err := oaDocCategoryIDsWithChildren(a, scope, p.CategoryID)
if err != nil {
return nil, 0, err
}
if len(ids) > 0 {
qs = qs.Filter("category_id__in", ids)
} else {
// 所选分类不在当前空间,直接返回空结果
qs = qs.Filter("category_id__in", []uint64{})
}
}
if p.Status >= 0 {
@@ -559,6 +778,13 @@ func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) {
if list == nil {
list = []models.OaDoc{}
}
// 列表不返回体积较大的编辑源,仅标记是否在线制作
for i := range list {
if strings.TrimSpace(list[i].Content) != "" {
list[i].IsOnline = 1
}
list[i].Content = ""
}
return list, total, nil
}
@@ -603,20 +829,26 @@ func OaDocDownload(a OaDocActor, id uint64) (*models.OaDoc, error) {
type OaDocSaveParams struct {
SetCategory bool
CategoryID uint64
Title string
FileID uint64
FileURL string
FileName string
Ext string
Size uint64
Tags string
Summary string
Status int8
Version int
OwnerID uint64
OwnerName string
IsStar int8
Visibility int8 // 0-租户公开 1-私密
// SetVisibility 为 true 时才以 Visibility 覆盖原值,
// 避免"未传该字段"被当成"改为租户公开"(int8 零值无法区分未传与显式 0)
SetVisibility bool
Title string
FileID uint64
FileURL string
FileName string
Ext string
Size uint64
Tags string
Summary string
// Content 在线制作文档的编辑源(富文本 HTML),SetContent 为 true 时才覆盖
SetContent bool
Content string
Status int8
Version int
OwnerID uint64
OwnerName string
IsStar int8
Visibility int8 // 0-租户公开 1-私密
}
// OaDocCreate 新增文档。
@@ -632,12 +864,12 @@ func OaDocCreate(a OaDocActor, operatorName string, p OaDocSaveParams) (*models.
if title == "" {
return nil, ErrDocTitleEmpty
}
// 分类须与文档可见性同空间:共享(visibility=0)→共享空间;私密(visibility=1)→私密空间(绑定用户级别)。
if p.CategoryID > 0 {
ok, err := oaDocCategoryExists(tid, p.CategoryID)
if err != nil {
scope := oaDocScopeOfVisibility(p.Visibility)
if _, ok, err := oaDocCategoryUsable(a, scope, p.CategoryID); err != nil {
return nil, err
}
if !ok {
} else if !ok {
return nil, ErrDocCategoryInvalid
}
}
@@ -666,6 +898,7 @@ func OaDocCreate(a OaDocActor, operatorName string, p OaDocSaveParams) (*models.
DocType: models.DocTypeByExt(ext),
Tags: strings.TrimSpace(p.Tags),
Summary: strings.TrimSpace(p.Summary),
Content: p.Content,
Status: p.Status,
Version: version,
IsStar: p.IsStar,
@@ -703,23 +936,29 @@ func OaDocUpdate(a OaDocActor, id uint64, p OaDocSaveParams) (*models.OaDoc, err
}
fields := []string{"UpdateTime"}
if v := visibilityOf(p.Visibility); v != item.Visibility {
item.Visibility = v
fields = append(fields, "Visibility")
}
if p.SetCategory && p.CategoryID != item.CategoryID {
// 分类须与文档可见性同空间:以"更新后"的可见性推导范围
if p.CategoryID > 0 {
ok, err := oaDocCategoryExists(a.Tid, p.CategoryID)
if err != nil {
return nil, err
v := item.Visibility
if p.SetVisibility {
v = p.Visibility
}
if !ok {
scope := oaDocScopeOfVisibility(v)
if _, ok, err := oaDocCategoryUsable(a, scope, p.CategoryID); err != nil {
return nil, err
} else if !ok {
return nil, ErrDocCategoryInvalid
}
}
item.CategoryID = p.CategoryID
fields = append(fields, "CategoryID")
}
if p.SetVisibility {
if v := visibilityOf(p.Visibility); v != item.Visibility {
item.Visibility = v
fields = append(fields, "Visibility")
}
}
if title := strings.TrimSpace(p.Title); title != "" && title != item.Title {
item.Title = title
fields = append(fields, "Title")
@@ -775,6 +1014,11 @@ func OaDocUpdate(a OaDocActor, id uint64, p OaDocSaveParams) (*models.OaDoc, err
item.OwnerName = owner
fields = append(fields, "OwnerName")
}
// 在线文档编辑源:仅在显式传入时覆盖
if p.SetContent && p.Content != item.Content {
item.Content = p.Content
fields = append(fields, "Content")
}
if len(fields) == 1 {
return &item, nil
@@ -840,13 +1084,16 @@ func OaDocMove(a OaDocActor, ids []uint64, categoryID uint64) (int64, error) {
if len(ids) == 0 {
return 0, ErrDocIDEmpty
}
// 移动仅改变文档归属分类;分类须与每篇文档的可见性同空间(共享/私密)
if categoryID > 0 {
ok, err := oaDocCategoryExists(a.Tid, categoryID)
if err != nil {
return 0, err
}
if !ok {
return 0, ErrDocCategoryInvalid
var sample models.OaDoc
if _, err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id__in", ids), a).OrderBy("id").Limit(1).All(&sample); err == nil {
scope := oaDocScopeOfVisibility(sample.Visibility)
if _, ok, err := oaDocCategoryUsable(a, scope, categoryID); err != nil {
return 0, err
} else if !ok {
return 0, ErrDocCategoryInvalid
}
}
}
@@ -865,10 +1112,11 @@ func OaDocMove(a OaDocActor, ids []uint64, categoryID uint64) (int64, error) {
}
now := time.Now()
return oaDocBase(a.Tid).Filter("id__in", allowed).Update(orm.Params{
params := orm.Params{
"CategoryID": categoryID,
"UpdateTime": now,
})
}
return oaDocBase(a.Tid).Filter("id__in", allowed).Update(params)
}
// OaDocToggleStar 收藏 / 取消收藏切换,返回切换后的状态。
@@ -892,16 +1140,20 @@ func OaDocToggleStar(a OaDocActor, id uint64) (int8, error) {
// OaDocStatsResult 文档库概览统计
type OaDocStatsResult struct {
Total int64 `json:"total"`
Published int64 `json:"published"`
Draft int64 `json:"draft"`
Archived int64 `json:"archived"`
Starred int64 `json:"starred"`
Uncategorized int64 `json:"uncategorized"`
LinkCount int64 `json:"link_count"`
TypeCounts []OaDocTypeCount `json:"type_counts"`
RecentDocs []models.OaDoc `json:"recent_docs"`
HotDocs []models.OaDoc `json:"hot_docs"`
Total int64 `json:"total"`
Published int64 `json:"published"`
Draft int64 `json:"draft"`
Archived int64 `json:"archived"`
Starred int64 `json:"starred"`
Uncategorized int64 `json:"uncategorized"`
LinkCount int64 `json:"link_count"`
// 可见性维度概览(不受当前 visibility 筛选影响,始终反映全库口径):
PublicCount int64 `json:"public_count"` // 团队公开文档数
PrivateCount int64 `json:"private_count"` // 私密文档数
MineCount int64 `json:"mine_count"` // 我创建的文档数
TypeCounts []OaDocTypeCount `json:"type_counts"`
RecentDocs []models.OaDoc `json:"recent_docs"`
HotDocs []models.OaDoc `json:"hot_docs"`
}
// OaDocTypeCount 按文档类型统计
@@ -910,26 +1162,71 @@ type OaDocTypeCount struct {
Count int64 `json:"count"`
}
// OaDocStats 文档库概览:总数、状态分布、类型分布、最近更新与热门文档(均受可见性约束)。
func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
// OaDocStats 文档库概览:总数、状态分布、类型分布、最近更新与热门文档。
// scope 限定统计口径("shared" 共享文档空间 / "personal" 私密文档空间 / "all" 跨空间);可见性约束同样生效。
func OaDocStats(a OaDocActor, scope string) (*OaDocStatsResult, error) {
models.EnsureOaDocumentTables()
base := oaDocApplyVisible(oaDocBase(a.Tid), a)
scope = oaDocScopeOf(scope)
vis := int8(-1)
if scope != models.DocScopeAll {
vis = oaDocScopeVisibility(scope)
}
// 当前范围内的全部可见文档(私密仅创建者/被共享者可见)
allVisible := oaDocApplyVisible(oaDocBase(a.Tid), a)
if vis >= 0 {
allVisible = allVisible.Filter("visibility", vis)
}
mineCount, _ := allVisible.Filter("creator_id", a.UID).Count()
base := allVisible
total, _ := base.Count()
published, _ := base.Filter("status", models.DocStatusPublish).Count()
draft, _ := base.Filter("status", models.DocStatusDraft).Count()
archived, _ := base.Filter("status", models.DocStatusArchive).Count()
starred, _ := base.Filter("is_star", 1).Count()
uncategorized, _ := base.Filter("category_id", 0).Count()
linkCount, _ := models.Orm.QueryTable(new(models.OaDocLink)).Filter("tid", a.Tid).Count()
uncategorized, _ := oaDocApplyUncategorized(base, a, scope).Count()
// 关联数只统计"当前空间口径下文档发起"的连线
linkCount := int64(0)
linkExtra, linkExtraArgs := oaDocVisibilityRestrictSQL(a, "d.")
linkArgs := []interface{}{a.Tid}
if vis >= 0 {
linkArgs = append(linkArgs, vis)
}
linkArgs = append(linkArgs, linkExtraArgs...)
var linkRows []struct {
Cnt int64 `orm:"column(cnt)"`
}
visClause := ""
if vis >= 0 {
visClause = " AND d.visibility = ?"
}
_, _ = models.Orm.Raw(
`SELECT COUNT(*) AS cnt FROM yz_backend_oa_doc_link l
WHERE l.tid = ?`+visClause+` AND EXISTS (
SELECT 1 FROM yz_backend_oa_doc d
WHERE d.id = l.source_id AND d.is_deleted = 0`+linkExtra+")",
linkArgs...).QueryRows(&linkRows)
if len(linkRows) > 0 {
linkCount = linkRows[0].Cnt
}
var rows []struct {
DocType int8 `orm:"column(doc_type)"`
Cnt int64 `orm:"column(cnt)"`
}
typeExtra, typeExtraArgs := oaDocVisibilityRestrictSQL(a, "")
typeArgs := []interface{}{a.Tid}
if vis >= 0 {
typeArgs = append(typeArgs, vis)
}
typeArgs = append(typeArgs, typeExtraArgs...)
_, _ = models.Orm.Raw(
"SELECT doc_type, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0 GROUP BY doc_type ORDER BY cnt DESC",
a.Tid).QueryRows(&rows)
"SELECT doc_type, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0"+
visClause+typeExtra+" GROUP BY doc_type ORDER BY cnt DESC",
typeArgs...).QueryRows(&rows)
typeCounts := make([]OaDocTypeCount, 0, len(rows))
for _, r := range rows {
typeCounts = append(typeCounts, OaDocTypeCount{DocType: r.DocType, Count: r.Cnt})
@@ -946,6 +1243,17 @@ func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
hot = []models.OaDoc{}
}
publicCount, privateCount := int64(0), int64(0)
switch scope {
case models.DocScopeShared:
publicCount = total
case models.DocScopePersonal:
privateCount = total
case models.DocScopeAll:
publicCount, _ = oaDocApplyVisible(oaDocBase(a.Tid), a).Filter("visibility", models.DocVisibilityPublic).Count()
privateCount, _ = oaDocApplyVisible(oaDocBase(a.Tid), a).Filter("visibility", models.DocVisibilityPrivate).Count()
}
return &OaDocStatsResult{
Total: total,
Published: published,
@@ -954,6 +1262,9 @@ func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
Starred: starred,
Uncategorized: uncategorized,
LinkCount: linkCount,
PublicCount: publicCount,
PrivateCount: privateCount,
MineCount: mineCount,
TypeCounts: typeCounts,
RecentDocs: recent,
HotDocs: hot,
@@ -1371,13 +1682,13 @@ type OaDocRelationCount struct {
// OaDocGraphStats 图谱概览统计
type OaDocGraphStats struct {
DocCount int64 `json:"doc_count"`
LinkCount int64 `json:"link_count"`
CategoryCount int64 `json:"category_count"`
IsolatedCount int64 `json:"isolated_count"`
AvgDegree float64 `json:"avg_degree"`
DocCount int64 `json:"doc_count"`
LinkCount int64 `json:"link_count"`
CategoryCount int64 `json:"category_count"`
IsolatedCount int64 `json:"isolated_count"`
AvgDegree float64 `json:"avg_degree"`
RelationCounts []OaDocRelationCount `json:"relation_counts"`
TopNodes []OaDocGraphNode `json:"top_nodes"`
TopNodes []OaDocGraphNode `json:"top_nodes"`
}
// OaDocGraphResult 图谱数据
@@ -1402,7 +1713,8 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
}
docs, _, err := OaDocList(a, OaDocListParams{
Actor: a,
Actor: a,
Scope: models.DocScopeAll,
Keyword: p.Keyword,
CategoryID: p.CategoryID,
Status: -1,
@@ -1476,18 +1788,31 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
// 归属分类连线 + 分类中心节点
categoryCount := int64(0)
if p.IncludeCategory {
// 图谱包含两类分类节点:共享分类(user_id=0,Category=1)与当前用户自己的私密分类(Category=2)。
// 只取用户可见范围(共享 + 本人私密),避免把其他用户的私密分类暴露出来。
catCond := orm.NewCondition().
And("tid", a.Tid).
And("is_deleted", 0).
AndCond(orm.NewCondition().Or("user_id", 0).Or("user_id", a.UID))
var cats []models.OaDocCategory
if _, err := oaDocCategoryBase(a.Tid).OrderBy("sort", "id").All(&cats); err != nil && err != orm.ErrNoRows {
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).SetCond(catCond).OrderBy("sort", "id").All(&cats); err != nil && err != orm.ErrNoRows {
return nil, err
}
categoryCount = int64(len(cats))
used := make(map[uint64]bool, len(cats))
catByID := make(map[uint64]models.OaDocCategory, len(cats))
for _, c := range cats {
catByID[c.ID] = c
}
// 文档 → 分类 归属连线(仅当分类在当前可见范围内,避免出现指向不存在节点的悬空连线)
for _, d := range docs {
if d.CategoryID == 0 {
continue
}
used[d.CategoryID] = true
if _, ok := catByID[d.CategoryID]; !ok {
continue
}
links = append(links, OaDocGraphLink{
Source: fmt.Sprintf("d%d", d.ID),
Target: fmt.Sprintf("c%d", d.CategoryID),
@@ -1498,14 +1823,36 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
degree[fmt.Sprintf("d%d", d.ID)]++
degree[fmt.Sprintf("c%d", d.CategoryID)]++
}
// 分类 → 父分类 层级连线(补全分类树的上下级结构,避免顶级/上级分类缺失)
for _, c := range cats {
if !used[c.ID] {
if c.ParentID == 0 {
continue
}
if _, ok := catByID[c.ParentID]; !ok {
continue
}
links = append(links, OaDocGraphLink{
Source: fmt.Sprintf("c%d", c.ID),
Target: fmt.Sprintf("c%d", c.ParentID),
Relation: models.DocRelationBelong,
Label: "子分类",
Kind: "cate",
})
degree[fmt.Sprintf("c%d", c.ID)]++
degree[fmt.Sprintf("c%d", c.ParentID)]++
}
// 当前可见范围内的全部分类都作为中心节点,保证分类树完整
for _, c := range cats {
catKind := 1
if c.UserID != 0 {
catKind = 2 // 私密分类
}
nodes = append(nodes, OaDocGraphNode{
ID: fmt.Sprintf("c%d", c.ID),
Name: c.Name,
Category: 1,
Category: catKind,
CategoryID: c.ID,
})
}