feat: add i18n supports for console (#3506)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

为 Console 端添加多语言的支持,并默认提供简体中文和英文的语言包。

todolist:

- [x] 完善 Console 的文字语言包翻译。
- [ ] ~~为后端提供的部分数据支持翻译,比如系统设置的表单定义。(实现方式待讨论,这个 PR 先不支持)~~
- [x] 提供语言设置。

#### Which issue(s) this PR fixes:

Fixes #3346 

#### Special notes for your reviewer:

测试方式:

1. 检查各个页面的文字显示是否正常。
2. 测试中英文环境中是否使用了对应的语言包。

#### Does this PR introduce a user-facing change?

```release-note
Console 端支持多语言界面
```
pull/3548/head^2
Ryan Wang 2023-03-23 16:54:33 +08:00 committed by GitHub
parent c400c85922
commit b63d2b882c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
124 changed files with 4997 additions and 1411 deletions

View File

@ -1,2 +1 @@
# 排除 pnpm-lock.yaml防止被 prettier 影响导致不必要的 diff
pnpm-lock.yaml

View File

@ -68,6 +68,7 @@
"fastq": "^1.15.0",
"floating-vue": "2.0.0-beta.20",
"fuse.js": "^6.6.2",
"jsencrypt": "^3.3.2",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
@ -83,13 +84,13 @@
"vue-grid-layout": "3.0.0-beta1",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6",
"vuedraggable": "^4.1.0",
"jsencrypt": "^3.3.2"
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@changesets/cli": "^2.25.2",
"@iconify-json/mdi": "^1.1.36",
"@iconify-json/vscode-icons": "^1.1.16",
"@intlify/unplugin-vue-i18n": "^0.9.1",
"@rushstack/eslint-patch": "^1.2.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.0",

View File

@ -9,12 +9,16 @@ const props = withDefaults(
size?: number;
total?: number;
sizeOptions?: number[];
pageLabel?: string;
sizeLabel?: string;
}>(),
{
page: 1,
size: 10,
total: 0,
sizeOptions: () => [10],
pageLabel: "页",
sizeLabel: "条 / 页",
}
);
@ -115,7 +119,7 @@ const {
{{ i }} / {{ pageCount }}
</option>
</select>
<span class="text-sm text-gray-500"></span>
<span class="text-sm text-gray-500">{{ pageLabel }}</span>
</div>
<div class="inline-flex items-center gap-2">
<select
@ -130,7 +134,7 @@ const {
{{ sizeOption }}
</option>
</select>
<span class="text-sm text-gray-500"> / </span>
<span class="text-sm text-gray-500">{{ sizeLabel }}</span>
</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ importers:
'@halo-dev/richtext-editor': 0.0.0-alpha.19
'@iconify-json/mdi': ^1.1.36
'@iconify-json/vscode-icons': ^1.1.16
'@intlify/unplugin-vue-i18n': ^0.9.1
'@rushstack/eslint-patch': ^1.2.0
'@tailwindcss/aspect-ratio': ^0.4.2
'@tailwindcss/container-queries': ^0.1.0
@ -161,6 +162,7 @@ importers:
'@changesets/cli': 2.25.2
'@iconify-json/mdi': 1.1.36
'@iconify-json/vscode-icons': 1.1.16
'@intlify/unplugin-vue-i18n': 0.9.1_2zqjidl67vtf3eplgf74ci35dq
'@rushstack/eslint-patch': 1.2.0
'@tailwindcss/aspect-ratio': 0.4.2_tailwindcss@3.2.4
'@tailwindcss/container-queries': 0.1.0_tailwindcss@3.2.4
@ -367,6 +369,16 @@ packages:
jsesc: 2.5.2
dev: true
/@babel/generator/7.21.1:
resolution: {integrity: sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.21.2
'@jridgewell/gen-mapping': 0.3.2
'@jridgewell/trace-mapping': 0.3.17
jsesc: 2.5.2
dev: true
/@babel/helper-annotate-as-pure/7.18.6:
resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==}
engines: {node: '>=6.9.0'}
@ -379,7 +391,7 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-explode-assignable-expression': 7.18.6
'@babel/types': 7.20.7
'@babel/types': 7.21.2
dev: true
/@babel/helper-compilation-targets/7.20.7_@babel+core@7.20.12:
@ -450,7 +462,7 @@ packages:
resolution: {integrity: sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.20.7
'@babel/types': 7.21.2
dev: true
/@babel/helper-function-name/7.19.0:
@ -461,6 +473,14 @@ packages:
'@babel/types': 7.20.2
dev: true
/@babel/helper-function-name/7.21.0:
resolution: {integrity: sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/template': 7.20.7
'@babel/types': 7.21.2
dev: true
/@babel/helper-hoist-variables/7.18.6:
resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==}
engines: {node: '>=6.9.0'}
@ -520,7 +540,7 @@ packages:
'@babel/helper-annotate-as-pure': 7.18.6
'@babel/helper-environment-visitor': 7.18.9
'@babel/helper-wrap-function': 7.19.0
'@babel/types': 7.20.7
'@babel/types': 7.21.2
transitivePeerDependencies:
- supports-color
dev: true
@ -549,7 +569,7 @@ packages:
resolution: {integrity: sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.20.7
'@babel/types': 7.21.2
dev: true
/@babel/helper-split-export-declaration/7.18.6:
@ -576,10 +596,10 @@ packages:
resolution: {integrity: sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-function-name': 7.19.0
'@babel/helper-function-name': 7.21.0
'@babel/template': 7.20.7
'@babel/traverse': 7.20.12
'@babel/types': 7.20.7
'@babel/traverse': 7.21.2
'@babel/types': 7.21.2
transitivePeerDependencies:
- supports-color
dev: true
@ -618,6 +638,14 @@ packages:
dependencies:
'@babel/types': 7.20.7
/@babel/parser/7.21.2:
resolution: {integrity: sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.20.7
dev: true
/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/7.18.6_@babel+core@7.20.12:
resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==}
engines: {node: '>=6.9.0'}
@ -1037,7 +1065,7 @@ packages:
'@babel/helper-annotate-as-pure': 7.18.6
'@babel/helper-compilation-targets': 7.20.7_@babel+core@7.20.12
'@babel/helper-environment-visitor': 7.18.9
'@babel/helper-function-name': 7.19.0
'@babel/helper-function-name': 7.21.0
'@babel/helper-optimise-call-expression': 7.18.6
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-replace-supers': 7.19.1
@ -1117,7 +1145,7 @@ packages:
dependencies:
'@babel/core': 7.20.12
'@babel/helper-compilation-targets': 7.20.7_@babel+core@7.20.12
'@babel/helper-function-name': 7.19.0
'@babel/helper-function-name': 7.21.0
'@babel/helper-plugin-utils': 7.20.2
dev: true
@ -1436,7 +1464,7 @@ packages:
'@babel/plugin-transform-unicode-escapes': 7.18.10_@babel+core@7.20.12
'@babel/plugin-transform-unicode-regex': 7.18.6_@babel+core@7.20.12
'@babel/preset-modules': 0.1.5_@babel+core@7.20.12
'@babel/types': 7.20.7
'@babel/types': 7.21.2
babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.20.12
babel-plugin-polyfill-corejs3: 0.6.0_@babel+core@7.20.12
babel-plugin-polyfill-regenerator: 0.4.1_@babel+core@7.20.12
@ -1455,7 +1483,7 @@ packages:
'@babel/helper-plugin-utils': 7.20.2
'@babel/plugin-proposal-unicode-property-regex': 7.18.6_@babel+core@7.20.12
'@babel/plugin-transform-dotall-regex': 7.18.6_@babel+core@7.20.12
'@babel/types': 7.20.7
'@babel/types': 7.21.2
esutils: 2.0.3
dev: true
@ -1525,6 +1553,24 @@ packages:
- supports-color
dev: true
/@babel/traverse/7.21.2:
resolution: {integrity: sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/code-frame': 7.18.6
'@babel/generator': 7.21.1
'@babel/helper-environment-visitor': 7.18.9
'@babel/helper-function-name': 7.21.0
'@babel/helper-hoist-variables': 7.18.6
'@babel/helper-split-export-declaration': 7.18.6
'@babel/parser': 7.21.2
'@babel/types': 7.21.2
debug: 4.3.4
globals: 11.12.0
transitivePeerDependencies:
- supports-color
dev: true
/@babel/types/7.20.2:
resolution: {integrity: sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog==}
engines: {node: '>=6.9.0'}
@ -1541,6 +1587,15 @@ packages:
'@babel/helper-validator-identifier': 7.19.1
to-fast-properties: 2.0.0
/@babel/types/7.21.2:
resolution: {integrity: sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-string-parser': 7.19.4
'@babel/helper-validator-identifier': 7.19.1
to-fast-properties: 2.0.0
dev: true
/@bcoe/v8-coverage/0.2.3:
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
dev: true
@ -2576,6 +2631,30 @@ packages:
resolution: {integrity: sha512-sZAW08CkqgvqRjUIaLRjScjObcCzN9D75yekLA21EClYAZIhi4A+GEt2z/WqOCOksTaEPLYmQyhkpXcboc0LhQ==}
dev: false
/@intlify/bundle-utils/5.0.1_vue-i18n@9.2.2:
resolution: {integrity: sha512-qF6reZHDm+h7jUId2npzwNZYCvrUcr0bAYnJXgiShKBxTFpxOq7nrqy7UrRWj8M2w4GTCJczeQZmQGFxc/GdFA==}
engines: {node: '>= 12'}
peerDependencies:
petite-vue-i18n: '*'
vue-i18n: '*'
peerDependenciesMeta:
petite-vue-i18n:
optional: true
vue-i18n:
optional: true
dependencies:
'@babel/parser': 7.21.2
'@babel/traverse': 7.21.2
'@intlify/message-compiler': 9.3.0-beta.16
'@intlify/shared': 9.3.0-beta.16
jsonc-eslint-parser: 1.4.1
source-map: 0.6.1
vue-i18n: 9.2.2_vue@3.2.45
yaml-eslint-parser: 0.3.2
transitivePeerDependencies:
- supports-color
dev: true
/@intlify/core-base/9.2.2:
resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==}
engines: {node: '>= 14'}
@ -2584,14 +2663,12 @@ packages:
'@intlify/message-compiler': 9.2.2
'@intlify/shared': 9.2.2
'@intlify/vue-devtools': 9.2.2
dev: false
/@intlify/devtools-if/9.2.2:
resolution: {integrity: sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==}
engines: {node: '>= 14'}
dependencies:
'@intlify/shared': 9.2.2
dev: false
/@intlify/message-compiler/9.2.2:
resolution: {integrity: sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==}
@ -2599,12 +2676,56 @@ packages:
dependencies:
'@intlify/shared': 9.2.2
source-map: 0.6.1
dev: false
/@intlify/message-compiler/9.3.0-beta.16:
resolution: {integrity: sha512-CGQI3xRcs1ET75eDQ0DUy3MRYOqTauRIIgaMoISKiF83gqRWg93FqN8lGMKcpBqaF4tI0JhsfosCaGiBL9+dnw==}
engines: {node: '>= 14'}
dependencies:
'@intlify/shared': 9.3.0-beta.16
source-map: 0.6.1
dev: true
/@intlify/shared/9.2.2:
resolution: {integrity: sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==}
engines: {node: '>= 14'}
dev: false
/@intlify/shared/9.3.0-beta.16:
resolution: {integrity: sha512-kXbm4svALe3lX+EjdJxfnabOphqS4yQ1Ge/iIlR8tvUiYRCoNz3hig1M4336iY++Dfx5ytEQJPNjIcknNIuvig==}
engines: {node: '>= 14'}
dev: true
/@intlify/unplugin-vue-i18n/0.9.1_2zqjidl67vtf3eplgf74ci35dq:
resolution: {integrity: sha512-7HLs5VjzK702Bqofrq9aA2yujcg8EJEtikspr0Zx6wXuI5rYq5VnejUWns+ZTdRjqfbd6DkFGNBooOLVfkezBQ==}
engines: {node: '>= 14.16'}
peerDependencies:
petite-vue-i18n: '*'
vue-i18n: '*'
vue-i18n-bridge: '*'
peerDependenciesMeta:
petite-vue-i18n:
optional: true
vue-i18n:
optional: true
vue-i18n-bridge:
optional: true
dependencies:
'@intlify/bundle-utils': 5.0.1_vue-i18n@9.2.2
'@intlify/shared': 9.3.0-beta.16
'@rollup/pluginutils': 5.0.2_rollup@2.79.1
'@vue/compiler-sfc': 3.2.47
debug: 4.3.4
fast-glob: 3.2.12
js-yaml: 4.1.0
json5: 2.2.3
pathe: 1.1.0
picocolors: 1.0.0
source-map: 0.6.1
unplugin: 1.3.0
vue-i18n: 9.2.2_vue@3.2.45
transitivePeerDependencies:
- rollup
- supports-color
dev: true
/@intlify/vue-devtools/9.2.2:
resolution: {integrity: sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==}
@ -2612,7 +2733,6 @@ packages:
dependencies:
'@intlify/core-base': 9.2.2
'@intlify/shared': 9.2.2
dev: false
/@istanbuljs/schema/0.1.3:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
@ -2664,6 +2784,13 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@jridgewell/trace-mapping/0.3.17:
resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==}
dependencies:
'@jridgewell/resolve-uri': 3.1.0
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@jsdevtools/ez-spawn/3.0.4:
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
engines: {node: '>=10'}
@ -3038,6 +3165,21 @@ packages:
picomatch: 2.3.1
dev: true
/@rollup/pluginutils/5.0.2_rollup@2.79.1:
resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0
peerDependenciesMeta:
rollup:
optional: true
dependencies:
'@types/estree': 1.0.0
estree-walker: 2.0.2
picomatch: 2.3.1
rollup: 2.79.1
dev: true
/@rushstack/eslint-patch/1.2.0:
resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==}
dev: true
@ -4178,12 +4320,28 @@ packages:
estree-walker: 2.0.2
source-map: 0.6.1
/@vue/compiler-core/3.2.47:
resolution: {integrity: sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==}
dependencies:
'@babel/parser': 7.20.7
'@vue/shared': 3.2.47
estree-walker: 2.0.2
source-map: 0.6.1
dev: true
/@vue/compiler-dom/3.2.45:
resolution: {integrity: sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==}
dependencies:
'@vue/compiler-core': 3.2.45
'@vue/shared': 3.2.45
/@vue/compiler-dom/3.2.47:
resolution: {integrity: sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==}
dependencies:
'@vue/compiler-core': 3.2.47
'@vue/shared': 3.2.47
dev: true
/@vue/compiler-sfc/3.2.45:
resolution: {integrity: sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==}
dependencies:
@ -4198,15 +4356,36 @@ packages:
postcss: 8.4.19
source-map: 0.6.1
/@vue/compiler-sfc/3.2.47:
resolution: {integrity: sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==}
dependencies:
'@babel/parser': 7.20.7
'@vue/compiler-core': 3.2.47
'@vue/compiler-dom': 3.2.47
'@vue/compiler-ssr': 3.2.47
'@vue/reactivity-transform': 3.2.47
'@vue/shared': 3.2.47
estree-walker: 2.0.2
magic-string: 0.25.9
postcss: 8.4.21
source-map: 0.6.1
dev: true
/@vue/compiler-ssr/3.2.45:
resolution: {integrity: sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==}
dependencies:
'@vue/compiler-dom': 3.2.45
'@vue/shared': 3.2.45
/@vue/compiler-ssr/3.2.47:
resolution: {integrity: sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==}
dependencies:
'@vue/compiler-dom': 3.2.47
'@vue/shared': 3.2.47
dev: true
/@vue/devtools-api/6.4.5:
resolution: {integrity: sha512-JD5fcdIuFxU4fQyXUu3w2KpAJHzTVdN+p4iOX2lMWSHMOoQdMAcpFLZzm9Z/2nmsoZ1a96QEhZ26e50xLBsgOQ==}
dev: false
/@vue/eslint-config-prettier/7.0.0_5qrnzwqb344w6up62gv3safeoi:
resolution: {integrity: sha512-/CTc6ML3Wta1tCe1gUeO0EYnVXfo3nJXsIhZ8WJr3sov+cGASr6yuiibJTL6lmIBm7GobopToOuB3B6AWyV0Iw==}
@ -4250,6 +4429,16 @@ packages:
estree-walker: 2.0.2
magic-string: 0.25.9
/@vue/reactivity-transform/3.2.47:
resolution: {integrity: sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==}
dependencies:
'@babel/parser': 7.20.7
'@vue/compiler-core': 3.2.47
'@vue/shared': 3.2.47
estree-walker: 2.0.2
magic-string: 0.25.9
dev: true
/@vue/reactivity/3.2.45:
resolution: {integrity: sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==}
dependencies:
@ -4280,6 +4469,10 @@ packages:
/@vue/shared/3.2.45:
resolution: {integrity: sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==}
/@vue/shared/3.2.47:
resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==}
dev: true
/@vue/test-utils/2.2.4_vue@3.2.45:
resolution: {integrity: sha512-1JjLduJ84bFcuCt/1YLTNyktYeUHS/zA0u8iTmF6w6ul1K/nSvyKu/MC47YjdpZ4lI/hn7FH31B22kfz62e9wA==}
peerDependencies:
@ -4359,6 +4552,14 @@ packages:
acorn-walk: 8.2.0
dev: true
/acorn-jsx/5.3.2_acorn@7.4.1:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
acorn: 7.4.1
dev: true
/acorn-jsx/5.3.2_acorn@8.8.0:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -4400,6 +4601,12 @@ packages:
hasBin: true
dev: true
/acorn/8.8.2:
resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
/agent-base/6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@ -6276,6 +6483,13 @@ packages:
estraverse: 5.3.0
dev: true
/eslint-utils/2.1.0:
resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==}
engines: {node: '>=6'}
dependencies:
eslint-visitor-keys: 1.3.0
dev: true
/eslint-utils/3.0.0_eslint@8.28.0:
resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
@ -6286,6 +6500,11 @@ packages:
eslint-visitor-keys: 2.1.0
dev: true
/eslint-visitor-keys/1.3.0:
resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==}
engines: {node: '>=4'}
dev: true
/eslint-visitor-keys/2.1.0:
resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==}
engines: {node: '>=10'}
@ -6344,6 +6563,15 @@ packages:
- supports-color
dev: true
/espree/6.2.1:
resolution: {integrity: sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==}
engines: {node: '>=6.0.0'}
dependencies:
acorn: 7.4.1
acorn-jsx: 5.3.2_acorn@7.4.1
eslint-visitor-keys: 1.3.0
dev: true
/espree/9.4.0:
resolution: {integrity: sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -7694,6 +7922,17 @@ packages:
hasBin: true
dev: true
/jsonc-eslint-parser/1.4.1:
resolution: {integrity: sha512-hXBrvsR1rdjmB2kQmUjf1rEIa+TqHBGMge8pwi++C+Si1ad7EjZrJcpgwym+QGK/pqTx+K7keFAtLlVNdLRJOg==}
engines: {node: '>=8.10.0'}
dependencies:
acorn: 7.4.1
eslint-utils: 2.1.0
eslint-visitor-keys: 1.3.0
espree: 6.2.1
semver: 6.3.0
dev: true
/jsonc-parser/3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
@ -8580,6 +8819,10 @@ packages:
resolution: {integrity: sha512-c71n61F1skhj/jzZe+fWE9XDoTYjWbUwIKVwFftZ5IOgiX44BVkTkD+/803YDgR50tqeO4eXWxLyVHBLWQAD1g==}
dev: true
/pathe/1.1.0:
resolution: {integrity: sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w==}
dev: true
/pathval/1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
dev: true
@ -10304,6 +10547,15 @@ packages:
webpack-virtual-modules: 0.4.6
dev: true
/unplugin/1.3.0:
resolution: {integrity: sha512-l4Udjxg2+vCuKRgIA2T8fHd7UwKWaLizh7t+3C72zjnN0+ZS+odzATFenymOUgcGqG1dkCSYE34h9wBbMXrKrA==}
dependencies:
acorn: 8.8.2
chokidar: 3.5.3
webpack-sources: 3.2.3
webpack-virtual-modules: 0.5.0
dev: true
/untildify/4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}
@ -10859,7 +11111,6 @@ packages:
'@intlify/vue-devtools': 9.2.2
'@vue/devtools-api': 6.4.5
vue: 3.2.45
dev: false
/vue-resize/2.0.0-alpha.1_vue@3.2.45:
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
@ -10966,6 +11217,10 @@ packages:
resolution: {integrity: sha512-5tyDlKLqPfMqjT3Q9TAqf2YqjwmnUleZwzJi1A5qXnlBCdj2AtOJ6wAWdglTIDOPgOiOrXeBeFcsQ8+aGQ6QbA==}
dev: true
/webpack-virtual-modules/0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
dev: true
/whatwg-encoding/2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
@ -11260,6 +11515,14 @@ packages:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
dev: true
/yaml-eslint-parser/0.3.2:
resolution: {integrity: sha512-32kYO6kJUuZzqte82t4M/gB6/+11WAuHiEnK7FreMo20xsCKPeFH5tDBU7iWxR7zeJpNnMXfJyXwne48D0hGrg==}
dependencies:
eslint-visitor-keys: 1.3.0
lodash: 4.17.21
yaml: 1.10.2
dev: true
/yaml/1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}

View File

@ -6,6 +6,9 @@ import { useFavicon } from "@vueuse/core";
import { useSystemConfigMapStore } from "./stores/system-configmap";
import { storeToRefs } from "pinia";
import axios from "axios";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const { configMap } = storeToRefs(useSystemConfigMapStore());
@ -18,7 +21,7 @@ watch(
() => {
const { title: routeTitle } = route.meta;
if (routeTitle) {
title.value = `${routeTitle} - ${AppName}`;
title.value = `${t(routeTitle)} - ${AppName}`;
return;
}
title.value = AppName;

View File

@ -2,6 +2,9 @@
import { VButton } from "@halo-dev/components";
import { useMagicKeys } from "@vueuse/core";
import { computed, useAttrs, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -21,7 +24,10 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
const attrs = useAttrs();
const buttonText = computed(() => {
return `${props.text} ${isMac ? "⌘" : "Ctrl"} + ↵`;
return t("core.components.submit_button.computed_text", {
text: props.text,
shortcut: `${isMac ? "⌘" : "Ctrl"} + ↵`,
});
});
const { Command_Enter, Ctrl_Enter } = useMagicKeys();

View File

@ -79,7 +79,7 @@ const searchResults = computed(() => {
<FormKit
id="categoryDropdownSelectorInput"
v-model="keyword"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
></FormKit>
</div>
@ -107,7 +107,11 @@ const searchResults = computed(() => {
</template>
<template #end>
<VEntityField
:description="`${category.status?.postCount || 0} 篇文章`"
:description="
$t('core.common.fields.post_count', {
count: category.status?.postCount || 0,
})
"
/>
</template>
</VEntity>

View File

@ -77,7 +77,7 @@ const searchResults = computed(() => {
<FormKit
id="tagDropdownSelectorInput"
v-model="keyword"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
></FormKit>
</div>
@ -104,7 +104,11 @@ const searchResults = computed(() => {
</template>
<template #end>
<VEntityField
:description="`${tag.status?.postCount || 0} 篇文章`"
:description="
$t('core.common.fields.post_count', {
count: tag.status?.postCount || 0,
})
"
/>
</template>
</VEntity>

View File

@ -74,7 +74,7 @@ const searchResults = computed(() => {
<FormKit
id="userDropdownSelectorInput"
v-model="keyword"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
></FormKit>
</div>

View File

@ -110,6 +110,9 @@ import * as fastq from "fastq";
import type { queueAsPromised } from "fastq";
import type { Attachment } from "@halo-dev/api-client";
import { useFetchAttachmentPolicy } from "@/modules/contents/attachments/composables/use-attachment-policy";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -195,7 +198,9 @@ const editor = useEditor({
ExtensionSubscript,
ExtensionSuperscript,
ExtensionPlaceholder.configure({
placeholder: "输入 / 以选择输入类型",
placeholder: t(
"core.components.default_editor.extensions.placeholder.options.placeholder"
),
}),
ExtensionHighlight,
ExtensionCommands.configure({
@ -356,7 +361,11 @@ const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
async function asyncWorker(arg: Task): Promise<void> {
if (!policies.value?.length) {
Toast.warning("目前没有可用的存储策略");
Toast.warning(
t(
"core.components.default_editor.upload_attachment.toast.no_available_policy"
)
);
return;
}
@ -377,7 +386,12 @@ const handleFetchPermalink = async (
maxRetry: number
): Promise<string | undefined> => {
if (maxRetry === 0) {
Toast.error(`获取附件永久链接失败:${attachment.spec.displayName}`);
Toast.error(
t(
"core.components.default_editor.upload_attachment.toast.failed_fetch_permalink",
{ display_name: attachment.spec.displayName }
)
);
return undefined;
}
@ -433,7 +447,7 @@ const toolbarMenuItems = computed(() => {
{
type: "button",
icon: markRaw(MdiFileImageBox),
title: "插入附件",
title: t("core.components.default_editor.toolbar.attachment"),
action: () => (attachmentSelectorModal.value = true),
isActive: () => false,
},
@ -536,7 +550,10 @@ watch(
<template #extra>
<div class="h-full w-72 overflow-y-auto border-l bg-white">
<VTabs v-model:active-id="extraActiveId" type="outline">
<VTabItem id="toc" label="大纲">
<VTabItem
id="toc"
:label="$t('core.components.default_editor.tabs.toc.title')"
>
<div class="p-1 pt-0">
<ul v-if="headingNodes?.length" class="space-y-1">
<li
@ -566,11 +583,16 @@ watch(
</li>
</ul>
<div v-else class="flex flex-col items-center py-10">
<span class="text-sm text-gray-600">暂无大纲</span>
<span class="text-sm text-gray-600">
{{ $t("core.components.default_editor.tabs.toc.empty") }}
</span>
</div>
</div>
</VTabItem>
<VTabItem id="information" label="详情">
<VTabItem
id="information"
:label="$t('core.components.default_editor.tabs.detail.title')"
>
<div class="flex flex-col gap-2 p-1 pt-0">
<div class="grid grid-cols-2 gap-2">
<div
@ -580,7 +602,11 @@ watch(
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
字符数
{{
$t(
"core.components.default_editor.tabs.detail.fields.character_count"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
@ -599,7 +625,11 @@ watch(
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
词数
{{
$t(
"core.components.default_editor.tabs.detail.fields.word_count"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
@ -621,7 +651,11 @@ watch(
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
发布时间
{{
$t(
"core.components.default_editor.tabs.detail.fields.publish_time"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCalendar
@ -630,7 +664,12 @@ watch(
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ formatDatetime(publishTime) || "未发布" }}
{{
formatDatetime(publishTime) ||
$t(
"core.components.default_editor.tabs.detail.fields.draft"
)
}}
</div>
</div>
</div>
@ -642,7 +681,11 @@ watch(
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
创建者
{{
$t(
"core.components.default_editor.tabs.detail.fields.owner"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconUserFollow
@ -663,7 +706,11 @@ watch(
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
访问链接
{{
$t(
"core.components.default_editor.tabs.detail.fields.permalink"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconLink

View File

@ -213,7 +213,11 @@ defineExpose({
@submit-invalid="onCustomFormSubmitCheck"
@submit="customFormInvalid = false"
>
<FormKit v-model="customAnnotationsState" type="repeater" label="自定义">
<FormKit
v-model="customAnnotationsState"
type="repeater"
:label="$t('core.components.annotations_form.custom_fields.label')"
>
<FormKit
type="text"
label="Key"
@ -221,7 +225,9 @@ defineExpose({
validation="required|keyValidationRule"
:validation-rules="{ keyValidationRule }"
:validation-messages="{
keyValidationRule: '当前 Key 已被占用',
keyValidationRule: $t(
'core.components.annotations_form.custom_fields.validation'
),
}"
></FormKit>
<FormKit

View File

@ -18,9 +18,11 @@ import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
const { activatedTheme } = storeToRefs(useThemeStore());
@ -78,7 +80,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconLink),
},
group: "后台页面",
group: t("core.components.global_search.groups.console"),
route,
});
});
@ -91,7 +93,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconUserSettings),
},
group: "用户",
group: t("core.components.global_search.groups.user"),
route: {
name: "UserDetail",
params: {
@ -113,7 +115,7 @@ const handleBuildSearchIndex = () => {
icon: {
src: plugin.status?.logo as string,
},
group: "插件",
group: t("core.components.global_search.groups.plugin"),
route: {
name: "PluginDetail",
params: {
@ -135,7 +137,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconBookRead),
},
group: "文章",
group: t("core.components.global_search.groups.post"),
route: {
name: "PostEditor",
query: {
@ -155,7 +157,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconBookRead),
},
group: "分类",
group: t("core.components.global_search.groups.category"),
route: {
name: "Categories",
query: {
@ -173,7 +175,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconBookRead),
},
group: "标签",
group: t("core.components.global_search.groups.tag"),
route: {
name: "Tags",
query: {
@ -195,7 +197,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconPages),
},
group: "自定义页面",
group: t("core.components.global_search.groups.page"),
route: {
name: "SinglePageEditor",
query: {
@ -217,7 +219,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconFolder),
},
group: "附件",
group: t("core.components.global_search.groups.attachment"),
route: {
name: "Attachments",
query: {
@ -242,7 +244,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconSettings),
},
group: "设置",
group: t("core.components.global_search.groups.setting"),
route: {
name: "SystemSetting",
params: {
@ -266,7 +268,7 @@ const handleBuildSearchIndex = () => {
icon: {
component: markRaw(IconPalette),
},
group: "主题设置",
group: t("core.components.global_search.groups.theme_setting"),
route: {
name: "ThemeSetting",
params: {
@ -379,7 +381,7 @@ const onVisibleChange = (visible: boolean) => {
<input
ref="globalSearchInput"
v-model="keyword"
placeholder="输入关键词以搜索"
:placeholder="$t('core.components.global_search.placeholder')"
class="w-full py-1 text-base outline-none"
autocomplete="off"
autocorrect="off"
@ -391,7 +393,7 @@ const onVisibleChange = (visible: boolean) => {
v-if="!searchResults.length"
class="flex items-center justify-center text-sm text-gray-500"
>
<span>没有搜索结果</span>
<span>{{ $t("core.components.global_search.no_results") }}</span>
</div>
<ul
v-if="searchResults.length > 0"
@ -436,7 +438,9 @@ const onVisibleChange = (visible: boolean) => {
</div>
<div class="border-t border-gray-100 px-4 py-2.5">
<div class="flex items-center justify-end">
<span class="mr-1 text-xs text-gray-600">选择</span>
<span class="mr-1 text-xs text-gray-600">
{{ $t("core.components.global_search.buttons.select") }}
</span>
<kbd
class="mr-1 w-5 rounded border p-0.5 text-center text-[10px] text-gray-600 shadow-sm"
>
@ -447,13 +451,17 @@ const onVisibleChange = (visible: boolean) => {
>
</kbd>
<span class="mr-1 text-xs text-gray-600">确认</span>
<span class="mr-1 text-xs text-gray-600">
{{ $t("core.common.buttons.confirm") }}
</span>
<kbd
class="mr-5 rounded border p-0.5 text-[10px] text-gray-600 shadow-sm"
>
Enter
</kbd>
<span class="mr-1 text-xs text-gray-600">关闭</span>
<span class="mr-1 text-xs text-gray-600">
{{ $t("core.common.buttons.close") }}
</span>
<kbd class="rounded border p-0.5 text-[10px] text-gray-600 shadow-sm">
Esc
</kbd>

View File

@ -10,12 +10,15 @@ import qs from "qs";
import { submitForm } from "@formkit/core";
import { JSEncrypt } from "jsencrypt";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import type {
GlobalInfo,
SocialAuthProvider,
} from "@/modules/system/actuator/types";
const { t } = useI18n();
const emit = defineEmits<{
(event: "succeed"): void;
}>();
@ -75,19 +78,21 @@ const handleLogin = async () => {
if (e instanceof AxiosError) {
if (/Network Error/.test(e.message)) {
Toast.error("网络错误,请检查网络连接");
Toast.error(t("core.common.toast.network_error"));
return;
}
if (e.response?.status === 403) {
Toast.warning("CSRF Token 失效,请重新尝试", { duration: 5000 });
Toast.warning(t("core.login.operations.submit.toast_csrf"), {
duration: 5000,
});
await handleGenerateToken();
return;
}
Toast.error("登录失败,用户名或密码错误");
Toast.error(t("core.login.operations.submit.toast_failed"));
} else {
Toast.error("未知异常");
Toast.error(t("core.common.toast.unknown_error"));
}
loginForm.value.password = "";
@ -132,10 +137,10 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
>
<FormKit
:validation-messages="{
required: '请输入用户名',
required: $t('core.login.fields.username.validation'),
}"
name="username"
placeholder="用户名"
:placeholder="$t('core.login.fields.username.placeholder')"
:autofocus="true"
type="text"
validation="required"
@ -144,10 +149,10 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
<FormKit
id="passwordInput"
:validation-messages="{
required: '请输入密码',
required: $t('core.login.fields.password.validation'),
}"
name="password"
placeholder="密码"
:placeholder="$t('core.login.fields.password.placeholder')"
type="password"
validation="required"
>
@ -160,11 +165,13 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
type="secondary"
@click="submitForm('login-form')"
>
登录
{{ $t("core.login.button") }}
</VButton>
<div v-if="socialAuthProviders?.length" class="mt-3 flex items-center">
<span class="text-sm text-slate-600">其他登录</span>
<span class="text-sm text-slate-600">
{{ $t("core.login.other_login") }}
</span>
<ul class="flex items-center">
<li
v-for="(socialAuthProvider, index) in socialAuthProviders"

View File

@ -2,8 +2,10 @@
import { Toast, VModal } from "@halo-dev/components";
import LoginForm from "@/components/login/LoginForm.vue";
import { useUserStore } from "@/stores/user";
import { useI18n } from "vue-i18n";
const userStore = useUserStore();
const { t } = useI18n();
const onVisibleChange = (visible: boolean) => {
userStore.loginModalVisible = visible;
@ -11,7 +13,7 @@ const onVisibleChange = (visible: boolean) => {
const onLoginSucceed = () => {
onVisibleChange(false);
Toast.success("登录成功");
Toast.success(t("core.login.operations.submit.toast_success"));
};
</script>
@ -21,7 +23,7 @@ const onLoginSucceed = () => {
:mount-to-body="true"
:width="400"
:centered="true"
title="重新登录"
:title="$t('core.login.modal.title')"
@update:visible="onVisibleChange"
>
<LoginForm v-if="userStore.loginModalVisible" @succeed="onLoginSucceed" />

View File

@ -4,6 +4,7 @@ import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import { VMenu, VMenuItem, VMenuLabel } from "@halo-dev/components";
import type { RouteLocationMatched } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
const RoutesMenu = defineComponent({
name: "RoutesMenu",
@ -33,6 +34,8 @@ const RoutesMenu = defineComponent({
return <icon height="20px" width="20px" />;
}
const { t } = useI18n();
function renderItems(items: MenuItemType[] | undefined) {
return items?.map((item) => {
return (
@ -41,7 +44,7 @@ const RoutesMenu = defineComponent({
<VMenuItem
key={item.path}
id={item.path}
title={item.name}
title={t(item.name, item.name)}
v-slots={{
icon: () => renderIcon(item.icon),
}}
@ -52,7 +55,7 @@ const RoutesMenu = defineComponent({
<VMenuItem
key={item.path}
id={item.path}
title={item.name}
title={t(item.name, item.name)}
v-slots={{
icon: () => renderIcon(item.icon),
}}
@ -70,7 +73,7 @@ const RoutesMenu = defineComponent({
{props.menus?.map((menu: MenuGroupType) => {
return (
<>
{menu.name && <VMenuLabel>{menu.name}</VMenuLabel>}
{menu.name && <VMenuLabel>{t(menu.name, menu.name)}</VMenuLabel>}
{menu.items?.length && renderItems(menu.items)}
</>
);

View File

@ -7,6 +7,7 @@ interface ContentCache {
content?: string;
}
import debounce from "lodash.debounce";
import { useI18n } from "vue-i18n";
interface useContentCacheReturn {
handleResetCache: () => void;
@ -20,6 +21,7 @@ export function useContentCache(
raw: Ref<string | undefined>
): useContentCacheReturn {
const content_caches = useLocalStorage<ContentCache[]>(key, []);
const { t } = useI18n();
const handleResetCache = () => {
if (name) {
@ -27,7 +29,7 @@ export function useContentCache(
(c: ContentCache) => c.name === name
);
if (cache) {
Toast.info("已从缓存中恢复未保存的内容");
Toast.info(t("core.composables.content_cache.toast_recovered"));
raw.value = cache.content;
}
} else {
@ -35,7 +37,7 @@ export function useContentCache(
(c: ContentCache) => c.name === "" && c.content
);
if (cache) {
Toast.info("已从缓存中恢复未保存的内容");
Toast.info(t("core.composables.content_cache.toast_recovered"));
raw.value = cache.content;
}
}

View File

@ -4,6 +4,7 @@ import type { PluginModule } from "@/stores/plugin";
import { onMounted, ref, type Ref, defineAsyncComponent } from "vue";
import { VLoading } from "@halo-dev/components";
import Logo from "@/assets/logo.png";
import { useI18n } from "vue-i18n";
export interface EditorProvider extends EditorProviderRaw {
logo?: string;
@ -16,11 +17,12 @@ interface useEditorExtensionPointsReturn {
export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
// resolve plugin extension points
const { pluginModules } = usePluginModuleStore();
const { t } = useI18n();
const editorProviders = ref<EditorProvider[]>([
{
name: "default",
displayName: "默认编辑器",
displayName: t("core.plugin.extension_points.editor.providers.default"),
component: defineAsyncComponent({
loader: () => import("@/components/editor/DefaultEditor.vue"),
loadingComponent: VLoading,

View File

@ -10,6 +10,7 @@ import merge from "lodash.merge";
import type { ConfigMap, Setting, SettingForm } from "@halo-dev/api-client";
import type { FormKitSchemaCondition, FormKitSchemaNode } from "@formkit/core";
import { Toast } from "@halo-dev/components";
import { useI18n } from "vue-i18n";
const initialConfigMap: ConfigMap = {
apiVersion: "v1alpha1",
@ -35,6 +36,8 @@ export function useSettingForm(
settingName: Ref<string | undefined>,
configMapName: Ref<string | undefined>
): useSettingFormReturn {
const { t } = useI18n();
const setting = ref<Setting>();
const configMap = ref<ConfigMap>(cloneDeep(initialConfigMap));
const configMapFormData = ref<
@ -152,7 +155,7 @@ export function useSettingForm(
configMapName.value = data.metadata.name;
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to save configMap", e);
} finally {

View File

@ -294,7 +294,9 @@ const handleDelete = () => {
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
>
<span class="text-xs text-gray-700 group-hover:text-gray-900">
创建 {{ text }} 分类
{{
$t("core.formkit.category_select.creation_label", { text: text })
}}
</span>
</li>
<template v-if="text">

View File

@ -111,7 +111,7 @@ const handleMoveDown = (index: number) => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
添加
{{ $t("core.common.buttons.add") }}
</VButton>
</div>
</template>

View File

@ -285,7 +285,7 @@ const handleDelete = () => {
@click="handleCreateTag"
>
<span class="text-xs text-gray-700 group-hover:text-gray-900">
创建 {{ text }} 标签
{{ $t("core.formkit.tag_select.creation_label", { text: text }) }}
</span>
</li>
<li

View File

@ -30,9 +30,11 @@ import { useUserStore } from "@/stores/user";
import { rbacAnnotations } from "@/constants/annotations";
import { useScroll } from "@vueuse/core";
import { defineStore, storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false);
@ -43,7 +45,9 @@ const { currentRoles, currentUser } = storeToRefs(userStore);
const handleLogout = () => {
Dialog.warning({
title: "确定要退出登录吗?",
title: t("core.sidebar.operations.logout.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await axios.post(`${import.meta.env.VITE_API_URL}/logout`, undefined, {
@ -213,7 +217,11 @@ onMounted(() => {
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pt-5 pb-7">
<a href="/" target="_blank" title="访问首页">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
@ -228,7 +236,9 @@ onMounted(() => {
<span class="mr-3">
<IconSearch />
</span>
<span class="flex-1 select-none text-base font-normal">搜索</span>
<span class="flex-1 select-none text-base font-normal">
{{ $t("core.sidebar.search.placeholder") }}
</span>
<div class="text-sm">
{{ `${isMac ? "⌘" : "Ctrl"}+K` }}
</div>
@ -279,7 +289,7 @@ onMounted(() => {
params: { name: '-' },
}"
>
个人资料
{{ $t("core.sidebar.operations.profile.button") }}
</VButton>
<VButton
v-close-popper
@ -287,7 +297,7 @@ onMounted(() => {
type="default"
@click="handleLogout"
>
退出登录
{{ $t("core.sidebar.operations.logout.button") }}
</VButton>
</VSpace>
</div>
@ -339,7 +349,9 @@ onMounted(() => {
<div class="text-base">
<IconMore />
</div>
<div class="mt-0.5 text-xs">更多</div>
<div class="mt-0.5 text-xs">
{{ $t("core.sidebar.operations.more.button") }}
</div>
</div>
</div>
</div>

1140
console/src/locales/en.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,34 @@
import type { App } from "vue";
import { createI18n } from "vue-i18n";
import zh from "./lang/zh";
// @ts-ignore
import en from "./en.yaml";
// @ts-ignore
import zhCN from "./zh-CN.yaml";
const messages = {
zh,
en: en,
zh: zhCN,
"en-US": en,
"zh-CN": zhCN,
};
const i18n = createI18n({
legacy: false,
locale: "zh",
locale: "zh-CN",
fallbackLocale: "zh-CN",
messages,
});
export default i18n;
export function getBrowserLanguage(): string {
const browserLanguage = navigator.language;
const language = messages[browserLanguage]
? browserLanguage
: browserLanguage.split("-")[0];
return language in messages ? language : "zh-CN";
}
export function setupI18n(app: App) {
app.use(i18n);
}
export { i18n };

View File

@ -1,73 +0,0 @@
const zh = {
rbac: {
"Attachments Management": "附件",
"Attachment Manage": "附件管理",
"Attachment View": "附件查看",
"role-template-view-attachments": "附件查看",
"Comments Management": "评论",
"Comment Manage": "评论管理",
"Comment View": "评论查看",
"role-template-view-comments": "评论查看",
"ConfigMaps Management": "配置",
"ConfigMap Manage": "配置管理",
"ConfigMap View": "配置查看",
"role-template-view-configmaps": "配置查看",
"Menus Management": "菜单",
"Menu Manage": "菜单管理",
"Menu View": "菜单查看",
"role-template-view-menus": "菜单查看",
"Permissions Management": "权限",
"Permissions Manage": "权限管理",
"Permissions View": "权限查看",
"role-template-view-permissions": "权限查看",
"role-template-manage-permissions": "权限管理",
"Plugins Management": "插件",
"Plugin Manage": "插件管理",
"Plugin View": "插件查看",
"role-template-view-plugins": "插件查看",
"Posts Management": "文章",
"Post Manage": "文章管理",
"Post View": "文章查看",
"role-template-view-posts": "文章查看",
"role-template-manage-snaphosts": "版本管理",
"role-template-view-snaphosts": "版本查看",
"role-template-manage-tags": "标签管理",
"role-template-view-tags": "标签查看",
"role-template-manage-categories": "分类管理",
"role-template-view-categories": "分类查看",
"Roles Management": "角色",
"Role Manage": "角色管理",
"Role View": "角色查看",
"role-template-view-roles": "角色查看",
"Settings Management": "设置表单",
"Setting Manage": "设置表单管理",
"Setting View": "设置表单查看",
"role-template-view-settings": "设置表单查看",
"SinglePages Management": "页面",
"SinglePage Manage": "页面管理",
"SinglePage View": "页面查看",
"role-template-view-singlepages": "页面查看",
"Themes Management": "主题",
"Theme Manage": "主题管理",
"Theme View": "主题查看",
"role-template-view-themes": "主题查看",
"Users Management": "用户",
"User manage": "用户管理",
"User View": "用户查看",
"role-template-view-users": "用户查看",
"role-template-change-password": "修改密码",
},
};
export default zh;

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import { apiClient } from "@/utils/api-client";
// setup
import "./setup/setupStyles";
import { setupComponents } from "./setup/setupComponents";
import { setupI18n, i18n, getBrowserLanguage } from "./locales";
// core modules
import { coreModules } from "./modules";
import { useScriptTag } from "@vueuse/core";
@ -20,15 +21,14 @@ import { useThemeStore } from "./stores/theme";
import { useSystemStatesStore } from "./stores/system-states";
import { useUserStore } from "./stores/user";
import { useSystemConfigMapStore } from "./stores/system-configmap";
import i18n from "./locales";
import { VueQueryPlugin } from "@tanstack/vue-query";
const app = createApp(App);
setupComponents(app);
setupI18n(app);
app.use(createPinia());
app.use(i18n);
app.use(VueQueryPlugin);
function registerModule(pluginModule: PluginModule, core: boolean) {
@ -162,7 +162,10 @@ async function loadPluginModules() {
});
}
} catch (e) {
const message = `${plugin.metadata.name}: 加载插件入口文件失败`;
const message = i18n.global.t(
"core.plugin.loader.toast.entry_load_failed",
{ name: plugin.spec.displayName }
);
console.error(message, e);
pluginErrorMessages.push(message);
}
@ -172,7 +175,10 @@ async function loadPluginModules() {
try {
await loadStyle(`${import.meta.env.VITE_API_URL}${stylesheet}`);
} catch (e) {
const message = `${plugin.metadata.name}: 加载插件样式文件失败`;
const message = i18n.global.t(
"core.plugin.loader.toast.style_load_failed",
{ name: plugin.spec.displayName }
);
console.error(message, e);
pluginErrorMessages.push(message);
}
@ -238,6 +244,12 @@ async function initApp() {
const userStore = useUserStore();
await userStore.fetchCurrentUser();
// set locale
// @ts-ignore
i18n.global.locale.value =
userStore.currentUser?.metadata.annotations?.["locale"] ||
getBrowserLanguage();
if (userStore.isAnonymous) {
return;
}

View File

@ -42,10 +42,12 @@ import { useRouteQuery } from "@vueuse/router";
import { useFetchAttachmentGroup } from "./composables/use-attachment-group";
import { usePermission } from "@/utils/permission";
import FilterTag from "@/components/filter/FilterTag.vue";
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const policyVisible = ref(false);
const uploadVisible = ref(false);
@ -64,19 +66,19 @@ interface SortItem {
const SortItems: SortItem[] = [
{
label: "较近上传",
label: t("core.attachment.filters.sort.items.create_time_desc"),
value: "creationTimestamp,desc",
},
{
label: "较晚上传",
label: t("core.attachment.filters.sort.items.create_time_asc"),
value: "creationTimestamp,asc",
},
{
label: "文件大小降序",
label: t("core.attachment.filters.sort.items.size_desc"),
value: "size,desc",
},
{
label: "文件大小升序",
label: t("core.attachment.filters.sort.items.size_asc"),
value: "size,asc",
},
];
@ -180,7 +182,7 @@ const handleMove = async (group: Group) => {
await Promise.all(promises);
selectedAttachments.value.clear();
Toast.success("移动成功");
Toast.success(t("core.attachment.operations.move.toast_success"));
} catch (e) {
console.error(e);
} finally {
@ -229,12 +231,12 @@ const getPolicyName = (name: string | undefined) => {
const viewTypes = [
{
name: "list",
tooltip: "列表模式",
tooltip: t("core.attachment.filters.view_type.items.grid"),
icon: IconList,
},
{
name: "grid",
tooltip: "网格模式",
tooltip: t("core.attachment.filters.view_type.items.list"),
icon: IconGrid,
},
];
@ -299,7 +301,7 @@ onMounted(() => {
@close="onUploadModalClose"
/>
<AttachmentPoliciesModal v-model:visible="policyVisible" />
<VPageHeader title="附件库">
<VPageHeader :title="$t('core.attachment.title')">
<template #icon>
<IconFolder class="mr-2 self-center" />
</template>
@ -313,7 +315,7 @@ onMounted(() => {
<template #icon>
<IconDatabase2Line class="h-full w-full" />
</template>
存储策略
{{ $t("core.attachment.actions.storage_policies") }}
</VButton>
<VButton
v-permission="['system:attachments:manage']"
@ -323,7 +325,7 @@ onMounted(() => {
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传
{{ $t("core.common.buttons.upload") }}
</VButton>
</VSpace>
</template>
@ -357,7 +359,7 @@ onMounted(() => {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -365,44 +367,64 @@ onMounted(() => {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
<FilterTag
v-if="selectedPolicy"
@close="handleSelectPolicy(undefined)"
>
存储策略{{ selectedPolicy?.spec.displayName }}
{{
$t("core.attachment.filters.storage_policy.result", {
storage_policy: selectedPolicy.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedUser"
@close="handleSelectUser(undefined)"
>
上传者{{ selectedUser?.spec.displayName }}
{{
$t("core.attachment.filters.owner.result", {
owner: selectedUser.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedSortItem"
@click="handleSortItemChange()"
>
排序{{ selectedSortItem.label }}
{{
$t("core.common.filters.results.sort", {
sort: selectedSortItem.label,
})
}}
</FilterTag>
<FilteCleanButton
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
<VButton @click="selectedAttachments.clear()">
取消选择
{{
$t("core.attachment.operations.deselect_items.button")
}}
</VButton>
<FloatingDropdown>
<VButton>移动</VButton>
<VButton>
{{ $t("core.attachment.operations.move.button") }}
</VButton>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
@ -429,7 +451,11 @@ onMounted(() => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">存储策略</span>
<span class="mr-0.5">
{{
$t("core.attachment.filters.storage_policy.label")
}}
</span>
<span>
<IconArrowDown />
</span>
@ -464,7 +490,9 @@ onMounted(() => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">上传者</span>
<span class="mr-0.5">
{{ $t("core.attachment.filters.owner.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -503,7 +531,9 @@ onMounted(() => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.sort") }}
</span>
<span>
<IconArrowDown />
</span>
@ -545,7 +575,7 @@ onMounted(() => {
@click="handleFetchAttachments()"
>
<IconRefreshLine
v-tooltip="`刷新`"
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
@ -570,12 +600,14 @@ onMounted(() => {
<Transition v-else-if="!attachments?.length" appear name="fade">
<VEmpty
message="当前分组没有附件,你可以尝试刷新或者上传附件"
title="当前分组没有附件"
:message="$t('core.attachment.empty.message')"
:title="$t('core.attachment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments"></VButton>
<VButton @click="handleFetchAttachments">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:attachments:manage']"
type="secondary"
@ -584,7 +616,7 @@ onMounted(() => {
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传附件
{{ $t("core.attachment.empty.actions.upload") }}
</VButton>
</VSpace>
</template>
@ -624,14 +656,18 @@ onMounted(() => {
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">加载中...</span>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">加载异常</span>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
@ -652,7 +688,7 @@ onMounted(() => {
v-if="attachment.metadata.deletionTimestamp"
class="absolute top-1 right-1 text-xs text-red-300"
>
删除中...
{{ $t("core.common.status.deleting") }}...
</div>
<div
@ -748,7 +784,7 @@ onMounted(() => {
>
<template #description>
<VStatusDot
v-tooltip="`删除中`"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
@ -780,7 +816,7 @@ onMounted(() => {
type="danger"
@click="handleDelete(attachment)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>
@ -794,6 +830,8 @@ onMounted(() => {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[60, 120, 200]"
/>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace, VTag } from "@halo-dev/components";
import { VButton, VModal, VSpace } from "@halo-dev/components";
import LazyImage from "@/components/image/LazyImage.vue";
import type { Attachment } from "@halo-dev/api-client";
import prettyBytes from "pretty-bytes";
@ -69,7 +69,11 @@ const onVisibleChange = (visible: boolean) => {
</script>
<template>
<VModal
:title="`附件:${attachment?.spec.displayName || ''}`"
:title="
$t('core.attachment.detail_modal.title', {
display_name: attachment?.spec.displayName || '',
})
"
:visible="visible"
:width="1000"
:mount-to-body="mountToBody"
@ -86,7 +90,9 @@ const onVisibleChange = (visible: boolean) => {
class="flex justify-center"
>
<img
v-tooltip.bottom="`点击退出预览`"
v-tooltip.bottom="
$t('core.attachment.detail_modal.preview.click_to_exit')
"
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="w-auto cursor-pointer rounded"
@ -97,7 +103,9 @@ const onVisibleChange = (visible: boolean) => {
<div
class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">预览</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.preview") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<div
v-if="isImage(attachment?.spec.mediaType)"
@ -109,10 +117,14 @@ const onVisibleChange = (visible: boolean) => {
classes="max-w-full cursor-pointer rounded sm:max-w-[50%]"
>
<template #loading>
<span class="text-gray-400">加载中...</span>
<span class="text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</template>
<template #error>
<span class="text-red-400">加载异常</span>
<span class="text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</template>
</LazyImage>
</div>
@ -122,61 +134,86 @@ const onVisibleChange = (visible: boolean) => {
controls
class="max-w-full rounded sm:max-w-[50%]"
>
当前浏览器不支持该视频播放
{{
$t("core.attachment.detail_modal.preview.video_not_support")
}}
</video>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('audio/')">
<audio :src="attachment.status?.permalink" controls>
当前浏览器不支持该音频播放
{{
$t("core.attachment.detail_modal.preview.audio_not_support")
}}
</audio>
</div>
<span v-else> </span>
<span v-else>
{{ $t("core.attachment.detail_modal.preview.not_support") }}
</span>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">存储策略</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.storage_policy") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ policy?.spec.displayName }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">所在分组</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.group") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ getGroupName(attachment?.spec.groupName) || "未分组" }}
{{
getGroupName(attachment?.spec.groupName) ||
$t("core.attachment.common.text.ungrouped")
}}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件名称</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.display_name") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.displayName }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件类型</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.media_type") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.mediaType }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件大小</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.size") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ prettyBytes(attachment?.spec.size || 0) }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">上传者</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.owner") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.ownerName }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">上传时间</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.creation_time") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ formatDatetime(attachment?.metadata.creationTimestamp) }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">原始链接</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.attachment.detail_modal.fields.permalink") }}
</dt>
<dd
class="mt-1 text-sm text-gray-900 hover:text-blue-600 sm:col-span-2 sm:mt-0"
>
@ -185,63 +222,13 @@ const onVisibleChange = (visible: boolean) => {
</a>
</dd>
</div>
<!-- TODO: add attachment ref support -->
<div
v-if="false"
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">引用位置</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
// TODO
<ul class="mt-2 space-y-2">
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.3 发布了
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.2 发布
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
</ul>
</dd>
</div>
</dl>
</div>
<template #footer>
<VSpace>
<VButton type="default" @click="onVisibleChange(false)"
>关闭 Esc</VButton
>
<VButton type="default" @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close_and_shortcut") }}
</VButton>
<slot name="footer" />
</VSpace>
</template>

View File

@ -7,6 +7,7 @@ import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -24,6 +25,8 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const initialFormState: Group = {
spec: {
displayName: "",
@ -44,7 +47,9 @@ const isUpdateMode = computed(() => {
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑附件分组" : "新增附件分组";
return isUpdateMode.value
? t("core.attachment.group_editing_modal.titles.update")
: t("core.attachment.group_editing_modal.titles.create");
});
const handleSave = async () => {
@ -65,7 +70,7 @@ const handleSave = async () => {
);
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
onVisibleChange(false);
} catch (e) {
console.error("Failed to save attachment group", e);
@ -126,7 +131,9 @@ watch(
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
label="名称"
:label="
$t('core.attachment.group_editing_modal.fields.display_name.label')
"
type="text"
name="displayName"
validation="required|length:0,50"
@ -138,10 +145,13 @@ watch(
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('attachment-group-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -20,6 +20,9 @@ import type { Group } from "@halo-dev/api-client";
import { useRouteQuery } from "@vueuse/router";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -42,7 +45,7 @@ const emit = defineEmits<{
const defaultGroups: Group[] = [
{
spec: {
displayName: "全部",
displayName: t("core.attachment.group_list.internal_groups.all"),
},
apiVersion: "",
kind: "",
@ -52,7 +55,7 @@ const defaultGroups: Group[] = [
},
{
spec: {
displayName: "未分组",
displayName: t("core.attachment.common.text.ungrouped"),
},
apiVersion: "",
kind: "",
@ -91,9 +94,11 @@ const onEditingModalClose = () => {
const handleDelete = (group: Group) => {
Dialog.warning({
title: "确定要删除该分组吗?",
description: "将删除分组,并将分组下的附件移动至未分组,该操作不可恢复。",
title: t("core.attachment.group_list.operations.delete.title"),
description: t("core.attachment.group_list.operations.delete.title"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
// TODO:
const { data } = await apiClient.attachment.searchAttachments({
@ -123,16 +128,26 @@ const handleDelete = (group: Group) => {
emit("reload-attachments");
emit("update");
Toast.success(`删除成功,${data.total} 个附件已移动至未分组`);
Toast.success(
t("core.attachment.group_list.operations.delete.toast_success", {
total: data.total,
})
);
},
});
};
const handleDeleteWithAttachments = (group: Group) => {
Dialog.warning({
title: "确定要删除该分组吗?",
description: "将删除分组以及分组下的所有附件,该操作不可恢复。",
title: t(
"core.attachment.group_list.operations.delete_with_attachments.title"
),
description: t(
"core.attachment.group_list.operations.delete_with_attachments.description"
),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
// TODO:
const { data } = await apiClient.attachment.searchAttachments({
@ -157,7 +172,12 @@ const handleDeleteWithAttachments = (group: Group) => {
emit("reload-attachments");
emit("update");
Toast.success(`删除成功,${data.total} 个附件已被同时删除`);
Toast.success(
t(
"core.attachment.group_list.operations.delete_with_attachments.toast_success",
{ total: data.total }
)
);
},
});
};
@ -230,7 +250,7 @@ onMounted(async () => {
</span>
<VStatusDot
v-if="group.metadata.deletionTimestamp"
v-tooltip="`删除中`"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
@ -249,14 +269,16 @@ onMounted(async () => {
type="secondary"
@click="handleOpenEditingModal(group)"
>
重命名
{{ $t("core.attachment.group_list.operations.rename.button") }}
</VButton>
<FloatingDropdown
class="w-full"
placement="right"
:triggers="['click']"
>
<VButton block type="danger">删除</VButton>
<VButton block type="danger">
{{ $t("core.common.buttons.delete") }}
</VButton>
<template #popper>
<div class="w-52 p-2">
<VSpace class="w-full" direction="column">
@ -267,7 +289,11 @@ onMounted(async () => {
size="sm"
@click="handleDelete(group)"
>
删除并将附件移动至未分组
{{
$t(
"core.attachment.group_list.operations.delete.button"
)
}}
</VButton>
<VButton
v-close-popper.all
@ -276,7 +302,11 @@ onMounted(async () => {
size="sm"
@click="handleDeleteWithAttachments(group)"
>
删除并同时删除附件
{{
$t(
"core.attachment.group_list.operations.delete_with_attachments.button"
)
}}
</VButton>
</VSpace>
</div>
@ -294,7 +324,9 @@ onMounted(async () => {
@click="editingModal = true"
>
<div class="flex flex-1 items-center truncate">
<span class="truncate text-sm">添加分组</span>
<span class="truncate text-sm">
{{ $t("core.common.buttons.new") }}
</span>
</div>
<IconAddCircle />
</div>

View File

@ -20,6 +20,7 @@ import {
useFetchAttachmentPolicyTemplate,
} from "../composables/use-attachment-policy";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
withDefaults(
defineProps<{
@ -35,6 +36,8 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const { policies, isLoading, handleFetchPolicies } = useFetchAttachmentPolicy();
const { policyTemplates } = useFetchAttachmentPolicyTemplate();
@ -78,21 +81,31 @@ const handleDelete = async (policy: Policy) => {
if (data.total > 0) {
Dialog.warning({
title: "删除失败",
description: "该策略下存在附件,无法删除。",
title: t(
"core.attachment.policies_modal.operations.can_not_delete.title"
),
description: t(
"core.attachment.policies_modal.operations.can_not_delete.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
});
return;
}
Dialog.warning({
title: "确定要删除该策略吗?",
description: "当前策略下没有已上传的附件。",
title: t("core.attachment.policies_modal.operations.delete.title"),
description: t(
"core.attachment.policies_modal.operations.delete.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.extension.storage.policy.deletestorageHaloRunV1alpha1Policy(
{ name: policy.metadata.name }
);
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
handleFetchPolicies();
},
});
@ -107,14 +120,14 @@ const onEditingModalClose = () => {
<VModal
:visible="visible"
:width="750"
title="存储策略"
:title="$t('core.attachment.policies_modal.title')"
:body-class="['!p-0']"
:layer-closable="true"
@update:visible="onVisibleChange"
>
<template #actions>
<FloatingDropdown>
<span v-tooltip="`添加存储策略`">
<span v-tooltip="$t('core.common.buttons.new')">
<IconAddCircle />
</span>
<template #popper>
@ -138,18 +151,20 @@ const onEditingModalClose = () => {
</template>
<VEmpty
v-if="!policies?.length && !isLoading"
message="当前没有可用的存储策略,你可以尝试刷新或者新建策略"
title="当前没有可用的存储策略"
:message="$t('core.attachment.policies_modal.empty.message')"
:title="$t('core.attachment.policies_modal.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPolicies"></VButton>
<VButton @click="handleFetchPolicies">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<FloatingDropdown>
<VButton type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建策略
{{ $t("core.common.buttons.new") }}
</VButton>
<template #popper>
<div class="w-72 p-4">
@ -188,7 +203,11 @@ const onEditingModalClose = () => {
<template #end>
<VEntityField v-if="policy.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -206,7 +225,7 @@ const onEditingModalClose = () => {
type="secondary"
@click="handleOpenEditingModal(policy)"
>
编辑
{{ $t("core.common.buttons.edit") }}
</VButton>
<VButton
v-close-popper
@ -214,14 +233,16 @@ const onEditingModalClose = () => {
type="danger"
@click="handleDelete(policy)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>
</li>
</ul>
<template #footer>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close_and_shortcut") }}
</VButton>
</template>
</VModal>

View File

@ -12,6 +12,7 @@ import {
type FormKitSchemaNode,
} from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -29,6 +30,8 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const initialFormState: Policy = {
spec: {
displayName: "",
@ -97,8 +100,12 @@ const isUpdateMode = computed(() => {
const modalTitle = computed(() => {
return isUpdateMode.value
? `编辑策略:${props.policy?.spec.displayName}`
: `新增策略:${policyTemplate.value?.spec?.displayName}`;
? t("core.attachment.policy_editing_modal.titles.update", {
policy: props.policy?.spec.displayName,
})
: t("core.attachment.policy_editing_modal.titles.create", {
policy_template: policyTemplate.value?.spec?.displayName,
});
});
const handleSave = async () => {
@ -128,7 +135,7 @@ const handleSave = async () => {
);
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
onVisibleChange(false);
} catch (e) {
console.error("Failed to save attachment policy", e);
@ -214,7 +221,9 @@ const onVisibleChange = (visible: boolean) => {
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
label="名称"
:label="
$t('core.attachment.policy_editing_modal.fields.display_name.label')
"
type="text"
name="displayName"
validation="required|length:0,50"
@ -231,10 +240,13 @@ const onVisibleChange = (visible: boolean) => {
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('attachment-policy-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -8,6 +8,9 @@ import type {
PluginModule,
} from "@halo-dev/console-shared";
import { usePluginModuleStore } from "@/stores/plugin";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
withDefaults(
defineProps<{
@ -29,7 +32,7 @@ const selected = ref<AttachmentLike[]>([] as AttachmentLike[]);
const attachmentSelectProviders = ref<AttachmentSelectProvider[]>([
{
id: "core",
label: "附件库",
label: t("core.attachment.select_modal.providers.default.label"),
component: markRaw(CoreSelectorProvider),
},
]);
@ -88,7 +91,7 @@ const handleConfirm = () => {
:width="1240"
:mount-to-body="true"
:layer-closable="true"
title="选择附件"
:title="$t('core.attachment.select_modal.title')"
height="calc(100vh - 20px)"
@update:visible="onVisibleChange"
>
@ -111,15 +114,21 @@ const handleConfirm = () => {
v-model:selected="selected"
@change-provider="onChangeProvider"
></component>
<template #fallback> 加载中 </template>
<template #fallback>
{{ $t("core.common.status.loading") }}
</template>
</Suspense>
</template>
</div>
<template #footer>
<VButton type="secondary" @click="handleConfirm">
确定
{{ $t("core.common.buttons.confirm") }}
<span v-if="selected.length">
已选择 {{ selected.length }}
{{
$t("core.attachment.select_modal.operations.select.result", {
count: selected.length,
})
}}
</span>
</VButton>
</template>

View File

@ -114,17 +114,24 @@ watch(
:body-class="['!p-0']"
:visible="visible"
:width="650"
title="上传附件"
:title="$t('core.attachment.upload_modal.title')"
@update:visible="onVisibleChange"
>
<div class="w-full p-4">
<div class="mb-2">
<span class="text-sm text-gray-800">选择分组</span>
<span class="text-sm text-gray-800">
{{ $t("core.attachment.upload_modal.filters.group.label") }}
</span>
</div>
<div class="mb-3 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-4">
<div
v-for="(group, index) in [
{ metadata: { name: '' }, spec: { displayName: '未分组' } },
{
metadata: { name: '' },
spec: {
displayName: $t('core.attachment.common.text.ungrouped'),
},
},
...(groups || []),
]"
:key="index"
@ -143,7 +150,9 @@ watch(
</div>
</div>
<div class="mb-2">
<span class="text-sm text-gray-800">选择存储策略</span>
<span class="text-sm text-gray-800">
{{ $t("core.attachment.upload_modal.filters.policy.label") }}
</span>
</div>
<div class="mb-3 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-4">
<div
@ -171,7 +180,9 @@ watch(
class="flex h-full cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
>
<div class="flex flex-1 items-center truncate">
<span class="truncate text-sm">新建策略</span>
<span class="truncate text-sm">
{{ $t("core.common.buttons.new") }}
</span>
</div>
<IconAddCircle />
</div>
@ -196,8 +207,10 @@ watch(
</div>
<div v-if="!policies?.length" class="mb-3">
<VAlert
title="没有存储策略"
description="在上传之前,需要新建一个存储策略"
:title="$t('core.attachment.upload_modal.filters.policy.empty.title')"
:description="
$t('core.attachment.upload_modal.filters.policy.empty.description')
"
:closable="false"
/>
</div>
@ -210,7 +223,11 @@ watch(
groupName: selectedGroupName,
}"
:allowed-meta-fields="['policyName', 'groupName']"
:note="selectedPolicyName ? '' : '请先选择存储策略'"
:note="
selectedPolicyName
? ''
: $t('core.attachment.upload_modal.filters.policy.not_select')
"
/>
</div>
</VModal>

View File

@ -78,22 +78,24 @@ const handleOpenDetail = (attachment: Attachment) => {
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传
{{ $t("core.common.buttons.upload") }}
</VButton>
</div>
<VEmpty
v-if="!attachments?.length && !isLoading"
message="当前没有附件,你可以尝试刷新或者上传附件"
title="当前没有附件"
:message="$t('core.attachment.empty.message')"
:title="$t('core.attachment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments"></VButton>
<VButton @click="handleFetchAttachments">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary" @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传附件
{{ $t("core.attachment.empty.actions.upload") }}
</VButton>
</VSpace>
</template>
@ -126,12 +128,16 @@ const handleOpenDetail = (attachment: Attachment) => {
>
<template #loading>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-gray-400">加载中...</span>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-red-400">加载异常</span>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
@ -168,6 +174,8 @@ const handleOpenDetail = (attachment: Attachment) => {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[60, 120, 200]"
/>

View File

@ -6,6 +6,7 @@ import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import type { Content, Editor } from "@halo-dev/richtext-editor";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface useAttachmentControlReturn {
attachments: Ref<Attachment[] | undefined>;
@ -39,6 +40,8 @@ export function useAttachmentControl(filterOptions: {
page: Ref<number>;
size: Ref<number>;
}): useAttachmentControlReturn {
const { t } = useI18n();
const { user, policy, group, keyword, sort, page, size } = filterOptions;
const selectedAttachment = ref<Attachment>();
@ -124,9 +127,11 @@ export function useAttachmentControl(filterOptions: {
const handleDelete = (attachment: Attachment) => {
Dialog.warning({
title: "确定要删除该附件吗?",
description: "删除之后将无法恢复",
title: t("core.attachment.operations.delete.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.storage.attachment.deletestorageHaloRunV1alpha1Attachment(
@ -141,7 +146,7 @@ export function useAttachmentControl(filterOptions: {
}
selectedAttachments.value.delete(attachment);
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete attachment", e);
} finally {
@ -153,9 +158,11 @@ export function useAttachmentControl(filterOptions: {
const handleDeleteInBatch = () => {
Dialog.warning({
title: "确定要删除所选的附件吗?",
description: "删除之后将无法恢复",
title: t("core.attachment.operations.delete_in_batch.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const promises = Array.from(selectedAttachments.value).map(
@ -170,7 +177,7 @@ export function useAttachmentControl(filterOptions: {
await Promise.all(promises);
selectedAttachments.value.clear();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete attachments", e);
} finally {

View File

@ -19,10 +19,10 @@ export default definePlugin({
name: "Attachments",
component: AttachmentList,
meta: {
title: "附件",
title: "core.attachment.title",
permissions: ["system:attachments:view"],
menu: {
name: "附件",
name: "core.sidebar.menu.items.attachments",
group: "content",
icon: markRaw(IconFolder),
priority: 3,

View File

@ -22,6 +22,9 @@ import FilterTag from "@/components/filter/FilterTag.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const checkAll = ref(false);
const selectedComment = ref<ListedComment>();
@ -31,15 +34,15 @@ const keyword = ref("");
// Filters
const ApprovedFilterItems: { label: string; value?: boolean }[] = [
{
label: "全部",
label: t("core.comment.filters.status.items.all"),
value: undefined,
},
{
label: "已审核",
label: t("core.comment.filters.status.items.approved"),
value: true,
},
{
label: "待审核",
label: t("core.comment.filters.status.items.pending_review"),
value: false,
},
];
@ -51,19 +54,19 @@ const SortFilterItems: {
value?: Sort;
}[] = [
{
label: "默认",
label: t("core.comment.filters.sort.items.default"),
value: undefined,
},
{
label: "最后回复时间",
label: t("core.comment.filters.sort.items.last_reply_time"),
value: "LAST_REPLY_TIME",
},
{
label: "回复数",
label: t("core.comment.filters.sort.items.reply_count"),
value: "REPLY_COUNT",
},
{
label: "创建时间",
label: t("core.comment.filters.sort.items.creation_time"),
value: "CREATE_TIME",
},
];
@ -205,9 +208,13 @@ watch(
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除所选的评论吗?",
description: "将同时删除所有评论下的回复,该操作不可恢复。",
title: t("core.comment.operations.delete_comment_in_batch.title"),
description: t(
"core.comment.operations.delete_comment_in_batch.description"
),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const promises = selectedCommentNames.value.map((name) => {
@ -220,7 +227,7 @@ const handleDeleteInBatch = async () => {
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete comments", e);
} finally {
@ -232,7 +239,9 @@ const handleDeleteInBatch = async () => {
const handleApproveInBatch = async () => {
Dialog.warning({
title: "确定要审核通过所选的评论吗?",
title: t("core.comment.operations.approve_comment_in_batch.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const commentsToUpdate = comments.value?.filter((comment) => {
@ -262,7 +271,7 @@ const handleApproveInBatch = async () => {
await Promise.all(promises || []);
selectedCommentNames.value = [];
Toast.success("操作成功");
Toast.success(t("core.common.toast.operation_success"));
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
@ -273,7 +282,7 @@ const handleApproveInBatch = async () => {
};
</script>
<template>
<VPageHeader title="评论">
<VPageHeader :title="$t('core.comment.title')">
<template #icon>
<IconMessage class="mr-2 self-center" />
</template>
@ -305,7 +314,7 @@ const handleApproveInBatch = async () => {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -313,7 +322,11 @@ const handleApproveInBatch = async () => {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
<FilterTag
@ -322,21 +335,33 @@ const handleApproveInBatch = async () => {
handleApprovedFilterItemChange(ApprovedFilterItems[0])
"
>
状态{{ selectedApprovedFilterItem.label }}
{{
$t("core.common.filters.results.status", {
status: selectedApprovedFilterItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedUser"
@close="handleSelectUser(undefined)"
>
评论者{{ selectedUser?.spec.displayName }}
{{
$t("core.comment.filters.owner.result", {
owner: selectedUser.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedSortFilterItem.value != undefined"
@close="handleSortFilterItemChange(SortFilterItems[0])"
>
排序{{ selectedSortFilterItem.label }}
{{
$t("core.common.filters.results.sort", {
sort: selectedSortFilterItem.label,
})
}}
</FilterTag>
<FilterCleanButton
@ -346,10 +371,14 @@ const handleApproveInBatch = async () => {
</div>
<VSpace v-else>
<VButton type="secondary" @click="handleApproveInBatch">
审核通过
{{
$t(
"core.comment.operations.approve_comment_in_batch.button"
)
}}
</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
@ -359,7 +388,9 @@ const handleApproveInBatch = async () => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 状态 </span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.status") }}
</span>
<span>
<IconArrowDown />
</span>
@ -394,7 +425,9 @@ const handleApproveInBatch = async () => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">评论者</span>
<span class="mr-0.5">
{{ $t("core.comment.filters.owner.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -404,7 +437,9 @@ const handleApproveInBatch = async () => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 排序 </span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.sort") }}
</span>
<span>
<IconArrowDown />
</span>
@ -437,7 +472,7 @@ const handleApproveInBatch = async () => {
@click="refetch()"
>
<IconRefreshLine
v-tooltip="`刷新`"
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
@ -450,10 +485,15 @@ const handleApproveInBatch = async () => {
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!comments?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者修改筛选条件" title="当前没有评论">
<VEmpty
:message="$t('core.comment.empty.message')"
:title="$t('core.comment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch"></VButton>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
</VEmpty>
@ -488,6 +528,8 @@ const handleApproveInBatch = async () => {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[20, 30, 50, 100]"
/>

View File

@ -29,8 +29,10 @@ import type { RouteLocationRaw } from "vue-router";
import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -56,16 +58,18 @@ provide<Ref<ListedReply | undefined>>("hoveredReply", hoveredReply);
const handleDelete = async () => {
Dialog.warning({
title: "确认要删除该评论吗?",
description: "删除评论的同时会删除该评论下的所有回复,该操作不可恢复。",
title: t("core.comment.operations.delete_comment.title"),
description: t("core.comment.operations.delete_comment.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.comment.deletecontentHaloRunV1alpha1Comment({
name: props.comment?.comment?.metadata.name as string,
});
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (error) {
console.error("Failed to delete comment", error);
} finally {
@ -77,7 +81,9 @@ const handleDelete = async () => {
const handleApproveReplyInBatch = async () => {
Dialog.warning({
title: "确定要审核通过该评论的所有回复吗?",
title: t("core.comment.operations.approve_applies_in_batch.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const repliesToUpdate = replies.value?.filter((reply) => {
@ -99,7 +105,7 @@ const handleApproveReplyInBatch = async () => {
});
await Promise.all(promises || []);
Toast.success("操作成功");
Toast.success(t("core.common.toast.operation_success"));
} catch (e) {
console.error("Failed to approve comment replies in batch", e);
} finally {
@ -120,7 +126,7 @@ const handleApprove = async () => {
comment: commentToUpdate,
});
Toast.success("操作成功");
Toast.success(t("core.common.toast.operation_success"));
} catch (error) {
console.error("Failed to approve comment", error);
} finally {
@ -202,7 +208,7 @@ const SubjectRefProvider = ref<
Post: (subject: Extension): SubjectRefResult => {
const post = subject as Post;
return {
label: "文章",
label: t("core.comment.subject_refs.post"),
title: post.spec.title,
externalUrl: post.status?.permalink,
route: {
@ -218,7 +224,7 @@ const SubjectRefProvider = ref<
SinglePage: (subject: Extension): SubjectRefResult => {
const singlePage = subject as SinglePage;
return {
label: "单页",
label: t("core.comment.subject_refs.page"),
title: singlePage.spec.title,
externalUrl: singlePage.status?.permalink,
route: {
@ -236,8 +242,8 @@ const subjectRefResult = computed(() => {
const { subject } = props.comment;
if (!subject) {
return {
label: "未知",
title: "未知",
label: t("core.comment.subject_refs.unknown"),
title: t("core.comment.subject_refs.unknown"),
};
}
const subjectRef = SubjectRefProvider.value.find((provider) =>
@ -245,8 +251,8 @@ const subjectRefResult = computed(() => {
);
if (!subjectRef) {
return {
label: "未知",
title: "未知",
label: t("core.comment.subject_refs.unknown"),
title: t("core.comment.subject_refs.unknown"),
};
}
return subjectRef[subject.kind](subject);
@ -301,11 +307,15 @@ const subjectRefResult = computed(() => {
class="select-none text-gray-700 hover:text-gray-900"
@click="handleToggleShowReplies"
>
{{ comment?.comment?.status?.replyCount || 0 }} 条回复
{{
$t("core.comment.list.fields.reply_count", {
count: comment?.comment?.status?.replyCount || 0,
})
}}
</span>
<VStatusDot
v-if="comment?.comment?.status?.unreadReplyCount || 0 > 0"
v-tooltip="`有新的回复`"
v-tooltip="$t('core.comment.list.fields.has_new_replies')"
state="success"
animate
/>
@ -313,7 +323,7 @@ const subjectRefResult = computed(() => {
class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply"
>
回复
{{ $t("core.comment.operations.reply.button") }}
</span>
</div>
</div>
@ -341,14 +351,20 @@ const subjectRefResult = computed(() => {
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">待审核</span>
<span class="text-xs text-gray-500">
{{ $t("core.comment.list.fields.pending_review") }}
</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="comment?.comment?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -375,7 +391,7 @@ const subjectRefResult = computed(() => {
block
@click="handleApprove"
>
审核通过
{{ $t("core.comment.operations.approve_comment_in_batch.button") }}
</VButton>
<VButton
v-close-popper
@ -383,10 +399,10 @@ const subjectRefResult = computed(() => {
block
@click="handleApproveReplyInBatch"
>
审核通过所有回复
{{ $t("core.comment.operations.approve_applies_in_batch.button") }}
</VButton>
<VButton v-close-popper block type="danger" @click="handleDelete">
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
@ -397,15 +413,20 @@ const subjectRefResult = computed(() => {
>
<VLoading v-if="isLoading" />
<Transition v-else-if="!replies?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者创建新回复" title="当前没有回复">
<VEmpty
:message="$t('core.comment.reply_empty.message')"
:title="$t('core.comment.reply_empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch()"></VButton>
<VButton @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary" @click="replyModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
创建新回复
{{ $t("core.comment.reply_empty.new") }}
</VButton>
</VSpace>
</template>

View File

@ -20,6 +20,9 @@ import { reset } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { setFocus } from "@/formkit/utils/focus";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -106,7 +109,9 @@ const handleCreateReply = async () => {
});
onVisibleChange(false);
Toast.success("回复成功");
Toast.success(
t("core.comment.reply_modal.operations.submit.toast_success")
);
} catch (error) {
console.error("Failed to create comment reply", error);
} finally {
@ -145,7 +150,7 @@ watchEffect(() => {
<template>
<VModal
title="回复"
:title="$t('core.comment.reply_modal.title')"
:visible="visible"
:width="500"
@update:visible="onVisibleChange"
@ -161,7 +166,7 @@ watchEffect(() => {
:id="contentInputId"
v-model="formState.raw"
type="textarea"
validation-label="内容"
:validation-label="$t('core.comment.reply_modal.fields.content.label')"
:rows="6"
validation="required|length:0,1024"
></FormKit>
@ -182,10 +187,13 @@ watchEffect(() => {
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit(formId)"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -15,6 +15,9 @@ import { formatDatetime } from "@/utils/date";
import { apiClient } from "@/utils/api-client";
import { computed, inject, type Ref } from "vue";
import cloneDeep from "lodash.clonedeep";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -46,16 +49,18 @@ const quoteReply = computed(() => {
const handleDelete = async () => {
Dialog.warning({
title: "确认要删除该回复吗?",
description: "该操作不可恢复。",
title: t("core.comment.operations.delete_reply.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.reply.deletecontentHaloRunV1alpha1Reply({
name: props.reply?.reply.metadata.name as string,
});
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (error) {
console.error("Failed to delete comment reply", error);
} finally {
@ -76,7 +81,7 @@ const handleApprove = async () => {
reply: replyToUpdate,
});
Toast.success("操作成功");
Toast.success(t("core.common.toast.operation_success"));
} catch (error) {
console.error("Failed to approve comment reply", error);
} finally {
@ -146,7 +151,7 @@ const isHoveredReply = computed(() => {
class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply"
>
回复
{{ $t("core.comment.operations.reply.button") }}
</span>
<div v-if="false" class="flex items-center">
<VTag>New</VTag>
@ -161,14 +166,20 @@ const isHoveredReply = computed(() => {
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">待审核</span>
<span class="text-xs text-gray-500">
{{ $t("core.comment.list.fields.pending_review") }}
</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="reply?.reply.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -193,7 +204,7 @@ const isHoveredReply = computed(() => {
block
@click="handleApprove"
>
审核通过
{{ $t("core.comment.operations.approve_reply.button") }}
</VButton>
<VButton
v-permission="['system:comments:manage']"
@ -202,7 +213,7 @@ const isHoveredReply = computed(() => {
type="danger"
@click="handleDelete"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>

View File

@ -19,11 +19,11 @@ export default definePlugin({
name: "Comments",
component: CommentList,
meta: {
title: "评论",
title: "core.comment.title",
searchable: true,
permissions: ["system:comments:view"],
menu: {
name: "评论",
name: "core.sidebar.menu.items.comments",
group: "content",
icon: markRaw(IconMessage),
priority: 2,

View File

@ -16,7 +16,9 @@ const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</span>
<div>
<span class="text-sm text-gray-500">评论</span>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.comment_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.approvedComments }}
</p>

View File

@ -27,8 +27,10 @@ import { usePermission } from "@/utils/permission";
import { getNode } from "@formkit/core";
import FilterTag from "@/components/filter/FilterTag.vue";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false);
@ -87,9 +89,11 @@ const handleCheckAllChange = (e: Event) => {
const handleDeletePermanently = async (singlePage: SinglePage) => {
Dialog.warning({
title: "确认要永久删除该自定义页面吗?",
description: "删除之后将无法恢复",
title: t("core.deleted_page.operations.delete.title"),
description: t("core.deleted_page.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.extension.singlePage.deletecontentHaloRunV1alpha1SinglePage(
{
@ -98,16 +102,18 @@ const handleDeletePermanently = async (singlePage: SinglePage) => {
);
await refetch();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: "确定要确认永久删除选中的自定义页面吗?",
description: "删除之后将无法恢复",
title: t("core.deleted_page.operations.delete_in_batch.title"),
description: t("core.deleted_page.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
@ -121,15 +127,17 @@ const handleDeletePermanentlyInBatch = async () => {
await refetch();
selectedPageNames.value = [];
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleRecovery = async (singlePage: SinglePage) => {
Dialog.warning({
title: "确认要恢复该自定义页面吗?",
description: "该操作会将自定义页面恢复到被删除之前的状态",
title: t("core.deleted_page.operations.recovery.title"),
description: t("core.deleted_page.operations.recovery.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
const singlePageToUpdate = cloneDeep(singlePage);
singlePageToUpdate.spec.deleted = false;
@ -141,15 +149,19 @@ const handleRecovery = async (singlePage: SinglePage) => {
);
await refetch();
Toast.success("恢复成功");
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: "确认要恢复选中的自定义页面吗?",
description: "该操作会将自定义页面恢复到被删除之前的状态",
title: t("core.deleted_page.operations.recovery_in_batch.title"),
description: t(
"core.deleted_page.operations.recovery_in_batch.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
@ -178,7 +190,7 @@ const handleRecoveryInBatch = async () => {
await refetch();
selectedPageNames.value = [];
Toast.success("恢复成功");
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
@ -203,13 +215,15 @@ function handleClearKeyword() {
</script>
<template>
<VPageHeader title="自定义页面回收站">
<VPageHeader :title="$t('core.deleted_page.title')">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'SinglePages' }" size="sm">返回</VButton>
<VButton :route="{ name: 'SinglePages' }" size="sm">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
@ -218,7 +232,7 @@ function handleClearKeyword() {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -249,7 +263,7 @@ function handleClearKeyword() {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -257,15 +271,19 @@ function handleClearKeyword() {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
永久删除
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
恢复
{{ $t("core.common.buttons.recovery") }}
</VButton>
</VSpace>
</div>
@ -290,18 +308,20 @@ function handleClearKeyword() {
<VLoading v-if="isLoading" />
<Transition v-else-if="!singlePages?.length" appear name="fade">
<VEmpty
message="你可以尝试刷新或者返回自定义页面管理"
title="没有自定义页面被放入回收站"
:message="$t('core.deleted_page.empty.message')"
:title="$t('core.deleted_page.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch"></VButton>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:singlepages:view']"
:route="{ name: 'SinglePages' }"
type="primary"
>
返回
{{ $t("core.common.buttons.back") }}
</VButton>
</VSpace>
</template>
@ -330,10 +350,18 @@ function handleClearKeyword() {
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
访问量 {{ singlePage.stats.visit || 0 }}
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
评论 {{ singlePage.stats.totalComment || 0 }}
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</template>
@ -365,14 +393,22 @@ function handleClearKeyword() {
</VEntityField>
<VEntityField v-if="!singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="success" animate />
<VStatusDot
v-tooltip="$t('core.common.tooltips.recovering')"
state="success"
animate
/>
</template>
</VEntityField>
<VEntityField
v-if="singlePage?.page?.metadata.deletionTimestamp"
>
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -393,7 +429,7 @@ function handleClearKeyword() {
type="danger"
@click="handleDeletePermanently(singlePage.page)"
>
永久删除
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton
v-close-popper
@ -401,7 +437,7 @@ function handleClearKeyword() {
type="default"
@click="handleRecovery(singlePage.page)"
>
恢复
{{ $t("core.common.buttons.recovery") }}
</VButton>
</template>
</VEntity>
@ -414,6 +450,8 @@ function handleClearKeyword() {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[20, 30, 50, 100]"
/>

View File

@ -11,7 +11,6 @@ import {
Dialog,
} from "@halo-dev/components";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import PostPreviewModal from "../posts/components/PostPreviewModal.vue";
import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client";
import {
computed,
@ -34,8 +33,10 @@ import {
} from "@/composables/use-editor-extension-points";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";
const router = useRouter();
const { t } = useI18n();
// Editor providers
const { editorProviders } = useEditorExtensionPoints();
@ -90,7 +91,6 @@ const formState = ref<SinglePageRequest>(cloneDeep(initialFormState));
const saving = ref(false);
const publishing = ref(false);
const settingModal = ref(false);
const previewModal = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.page.metadata.creationTimestamp;
@ -118,7 +118,7 @@ const handleSave = async () => {
//Set default title and slug
if (!formState.value.page.spec.title) {
formState.value.page.spec.title = "无标题页面";
formState.value.page.spec.title = t("core.page_editor.untitled");
}
if (!formState.value.page.spec.slug) {
formState.value.page.spec.slug = new Date().getTime().toString();
@ -139,13 +139,13 @@ const handleSave = async () => {
routeQueryName.value = data.metadata.name;
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
handleClearCache(routeQueryName.value as string);
await handleFetchContent();
} catch (error) {
console.error("Failed to save single page", error);
Toast.error("保存失败,请重试");
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
saving.value = false;
}
@ -183,11 +183,11 @@ const handlePublish = async () => {
router.push({ name: "SinglePages" });
}
Toast.success("发布成功");
Toast.success(t("core.common.toast.publish_success"));
handleClearCache(routeQueryName.value as string);
} catch (error) {
console.error("Failed to publish single page", error);
Toast.error("发布失败,请重试");
Toast.error(t("core.common.toast.publish_failed_and_retry"));
} finally {
publishing.value = false;
}
@ -243,8 +243,12 @@ const handleFetchContent = async () => {
formState.value.page = data;
} else {
Dialog.warning({
title: "警告",
description: `未找到符合 ${data.rawType} 格式的编辑器,请检查是否已安装编辑器插件`,
title: t("core.common.dialog.titles.warning"),
description: t("core.common.dialog.descriptions.editor_not_found", {
raw_type: data.rawType,
}),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: () => {
router.back();
},
@ -330,8 +334,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<PostPreviewModal v-model:visible="previewModal" />
<VPageHeader title="自定义页面">
<VPageHeader :title="$t('core.page.title')">
<template #icon>
<IconPages class="mr-2 self-center" />
</template>
@ -342,21 +345,11 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
:provider="currentEditorProvider"
@select="handleChangeEditorProvider"
/>
<!-- TODO: add preview single page support -->
<VButton
v-if="false"
size="sm"
type="default"
@click="previewModal = true"
>
预览
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />
</template>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton
v-if="isUpdateMode"
@ -367,7 +360,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
<template #icon>
<IconSettings class="h-full w-full" />
</template>
设置
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
type="secondary"
@ -377,7 +370,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
发布
{{ $t("core.common.buttons.publish") }}
</VButton>
</VSpace>
</template>

View File

@ -5,7 +5,6 @@ import {
IconArrowRight,
IconEye,
IconEyeOff,
IconTeam,
IconAddCircle,
IconRefreshLine,
IconExternalLinkLine,
@ -38,8 +37,10 @@ import FilterTag from "@/components/filter/FilterTag.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const settingModal = ref(false);
const selectedSinglePage = ref<SinglePage>();
@ -65,57 +66,52 @@ interface SortItem {
const VisibleItems: VisibleItem[] = [
{
label: "全部",
label: t("core.page.filters.visible.items.all"),
value: undefined,
},
{
label: "公开",
label: t("core.page.filters.visible.items.public"),
value: "PUBLIC",
},
// TODO: 访
// {
// label: "访",
// value: "INTERNAL",
// },
{
label: "私有",
label: t("core.page.filters.visible.items.private"),
value: "PRIVATE",
},
];
const PublishStatusItems: PublishStatusItem[] = [
{
label: "全部",
label: t("core.page.filters.status.items.all"),
value: undefined,
},
{
label: "已发布",
label: t("core.page.filters.status.items.published"),
value: true,
},
{
label: "未发布",
label: t("core.page.filters.status.items.draft"),
value: false,
},
];
const SortItems: SortItem[] = [
{
label: "较近发布",
label: t("core.page.filters.sort.items.publish_time_desc"),
sort: "PUBLISH_TIME",
sortOrder: false,
},
{
label: "较早发布",
label: t("core.page.filters.sort.items.publish_time_asc"),
sort: "PUBLISH_TIME",
sortOrder: true,
},
{
label: "较近创建",
label: t("core.page.filters.sort.items.create_time_desc"),
sort: "CREATE_TIME",
sortOrder: false,
},
{
label: "较早创建",
label: t("core.page.filters.sort.items.create_time_asc"),
sort: "CREATE_TIME",
sortOrder: true,
},
@ -328,9 +324,11 @@ const handleCheckAllChange = (e: Event) => {
const handleDelete = async (singlePage: SinglePage) => {
Dialog.warning({
title: "确定要删除该自定义页面吗?",
description: "该操作会将自定义页面放入回收站,后续可以从回收站恢复",
title: t("core.page.operations.delete.title"),
description: t("core.page.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
const singlePageToUpdate = cloneDeep(singlePage);
singlePageToUpdate.spec.deleted = true;
@ -342,16 +340,18 @@ const handleDelete = async (singlePage: SinglePage) => {
);
await refetch();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除选中的自定义页面吗?",
description: "该操作会将自定义页面放入回收站,后续可以从回收站恢复",
title: t("core.page.operations.delete_in_batch.title"),
description: t("core.page.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
@ -380,14 +380,16 @@ const handleDeleteInBatch = async () => {
await refetch();
selectedPageNames.value = [];
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const getPublishStatus = (singlePage: SinglePage) => {
const { labels } = singlePage.metadata;
return labels?.[singlePageLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
return labels?.[singlePageLabels.PUBLISHED] === "true"
? t("core.page.filters.status.items.published")
: t("core.page.filters.status.items.draft");
};
const isPublishing = (singlePage: SinglePage) => {
@ -412,15 +414,15 @@ watch(selectedPageNames, (newValue) => {
>
<template #actions>
<span @click="handleSelectPrevious">
<IconArrowLeft v-tooltip="``" />
<IconArrowLeft v-tooltip="$t('core.common.buttons.previous')" />
</span>
<span @click="handleSelectNext">
<IconArrowRight v-tooltip="``" />
<IconArrowRight v-tooltip="$t('core.common.buttons.next')" />
</span>
</template>
</SinglePageSettingModal>
<VPageHeader title="页面">
<VPageHeader :title="$t('core.page.title')">
<template #icon>
<IconPages class="mr-2 self-center" />
</template>
@ -431,7 +433,7 @@ watch(selectedPageNames, (newValue) => {
:route="{ name: 'DeletedSinglePages' }"
size="sm"
>
回收站
{{ $t("core.page.actions.recycle_bin") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
@ -441,7 +443,7 @@ watch(selectedPageNames, (newValue) => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -473,7 +475,7 @@ watch(selectedPageNames, (newValue) => {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -481,35 +483,55 @@ watch(selectedPageNames, (newValue) => {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
<FilterTag
v-if="selectedPublishStatusItem.value !== undefined"
@close="handlePublishStatusItemChange(PublishStatusItems[0])"
>
状态{{ selectedPublishStatusItem.label }}
{{
$t("core.common.filters.results.status", {
status: selectedPublishStatusItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedVisibleItem.value"
@close="handleVisibleItemChange(VisibleItems[0])"
>
可见性{{ selectedVisibleItem.label }}
{{
$t("core.page.filters.visible.result", {
visible: selectedVisibleItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedContributor"
@close="handleSelectUser()"
>
作者{{ selectedContributor?.spec.displayName }}
{{
$t("core.page.filters.author.result", {
author: selectedContributor.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedSortItem"
@close="handleSortItemChange()"
>
排序{{ selectedSortItem.label }}
{{
$t("core.common.filters.results.sort", {
sort: selectedSortItem.label,
})
}}
</FilterTag>
<FilterCleanButton
@ -518,9 +540,9 @@ watch(selectedPageNames, (newValue) => {
/>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch"
>删除</VButton
>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
@ -529,7 +551,9 @@ watch(selectedPageNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">状态</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.status") }}
</span>
<span>
<IconArrowDown />
</span>
@ -559,7 +583,9 @@ watch(selectedPageNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 可见性 </span>
<span class="mr-0.5">
{{ $t("core.page.filters.visible.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -593,7 +619,9 @@ watch(selectedPageNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">作者</span>
<span class="mr-0.5">
{{ $t("core.page.filters.author.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -603,7 +631,9 @@ watch(selectedPageNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.sort") }}
</span>
<span>
<IconArrowDown />
</span>
@ -630,7 +660,7 @@ watch(selectedPageNames, (newValue) => {
@click="refetch()"
>
<IconRefreshLine
v-tooltip="`刷新`"
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
@ -643,10 +673,15 @@ watch(selectedPageNames, (newValue) => {
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!singlePages?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建页面" title="当前没有页面">
<VEmpty
:message="$t('core.page.empty.message')"
:title="$t('core.page.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch"></VButton>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
@ -655,7 +690,7 @@ watch(selectedPageNames, (newValue) => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建页面
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -691,7 +726,9 @@ watch(selectedPageNames, (newValue) => {
<VSpace>
<RouterLink
v-if="singlePage.page.status?.inProgress"
v-tooltip="`当前有内容已保存,但还未发布。`"
v-tooltip="
$t('core.common.tooltips.unpublished_content_tip')
"
:to="{
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
@ -715,10 +752,18 @@ watch(selectedPageNames, (newValue) => {
<div class="flex w-full flex-col gap-1">
<VSpace class="w-full">
<span class="text-xs text-gray-500">
访问量 {{ singlePage.stats.visit || 0 }}
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
评论 {{ singlePage.stats.totalComment || 0 }}
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</div>
@ -751,32 +796,33 @@ watch(selectedPageNames, (newValue) => {
</VEntityField>
<VEntityField :description="getPublishStatus(singlePage.page)">
<template v-if="isPublishing(singlePage.page)" #description>
<VStatusDot text="发布中" animate />
<VStatusDot
:text="$t('core.common.tooltips.publishing')"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<IconEye
v-if="singlePage.page.spec.visible === 'PUBLIC'"
v-tooltip="`公开访问`"
v-tooltip="$t('core.page.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<IconEyeOff
v-if="singlePage.page.spec.visible === 'PRIVATE'"
v-tooltip="`私有访问`"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<!-- TODO: 支持内部成员可访问 -->
<IconTeam
v-if="false"
v-tooltip="`内部成员可访问`"
v-tooltip="$t('core.page.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField v-if="singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -797,7 +843,7 @@ watch(selectedPageNames, (newValue) => {
type="secondary"
@click="handleOpenSettingModal(singlePage.page)"
>
设置
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
v-close-popper
@ -805,7 +851,7 @@ watch(selectedPageNames, (newValue) => {
type="danger"
@click="handleDelete(singlePage.page)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>
@ -818,6 +864,8 @@ watch(selectedPageNames, (newValue) => {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[20, 30, 50, 100]"
/>

View File

@ -18,6 +18,7 @@ import { submitForm } from "@formkit/core";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
import { useMutation } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const initialFormState: SinglePage = {
spec: {
@ -67,6 +68,8 @@ const emit = defineEmits<{
(event: "published", singlePage: SinglePage): void;
}>();
const { t } = useI18n();
const formState = ref<SinglePage>(cloneDeep(initialFormState));
const saving = ref(false);
const publishing = ref(false);
@ -142,7 +145,7 @@ const { mutateAsync: singlePageUpdateMutate } = useMutation({
retry: 3,
onError: (error) => {
console.error("Failed to update post", error);
Toast.error(`服务器内部错误`);
Toast.error(t("core.common.toast.server_internal_error"));
},
});
@ -182,7 +185,7 @@ const handleSave = async () => {
onVisibleChange(false);
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (error) {
console.error("Failed to save single page", error);
} finally {
@ -233,7 +236,7 @@ const handlePublish = async () => {
onVisibleChange(false);
Toast.success("发布成功");
Toast.success(t("core.common.toast.publish_success"));
} catch (error) {
console.error("Failed to publish single page", error);
} finally {
@ -265,7 +268,7 @@ const handleUnpublish = async () => {
onVisibleChange(false);
Toast.success("取消发布成功");
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (error) {
console.error("Failed to unpublish single page", error);
} finally {
@ -314,7 +317,7 @@ const { handleGenerateSlug } = useSlugify(
<VModal
:visible="visible"
:width="700"
title="页面设置"
:title="$t('core.page.settings.title')"
:centered="false"
@update:visible="onVisibleChange"
>
@ -334,29 +337,31 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
常规设置
{{ $t("core.page.settings.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.title"
label="标题"
:label="$t('core.page.settings.fields.title.label')"
type="text"
name="title"
validation="required|length:0,100"
></FormKit>
<FormKit
v-model="formState.spec.slug"
label="别名"
:label="$t('core.page.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
help="通常用于生成页面的固定链接"
:help="$t('core.page.settings.fields.slug.help')"
>
<template #suffix>
<div
v-tooltip="'根据标题重新生成别名'"
v-tooltip="
$t('core.page.settings.fields.slug.refresh_message')
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
@ -369,11 +374,13 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.excerpt.autoGenerate"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="autoGenerate"
label="自动生成摘要"
:label="
$t('core.page.settings.fields.auto_generate_excerpt.label')
"
type="radio"
>
</FormKit>
@ -381,7 +388,7 @@ const { handleGenerateSlug } = useSlugify(
v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.spec.excerpt.raw"
name="raw"
label="自定义摘要"
:label="$t('core.page.settings.fields.raw_excerpt.label')"
type="textarea"
validation="length:0,1024"
:rows="5"
@ -397,7 +404,7 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
高级设置
{{ $t("core.page.settings.groups.advanced") }}
</span>
</div>
</div>
@ -405,36 +412,39 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.allowComment"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="allowComment"
label="允许评论"
:label="$t('core.page.settings.fields.allow_comment.label')"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.pinned"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
label="是否置顶"
:label="$t('core.page.settings.fields.pinned.label')"
name="pinned"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.visible"
:options="[
{ label: '公开', value: 'PUBLIC' },
{ label: '私有', value: 'PRIVATE' },
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
label="可见性"
:label="$t('core.page.settings.fields.visible.label')"
name="visible"
type="select"
></FormKit>
<FormKit
:model-value="publishTime"
label="发表时间"
:label="$t('core.page.settings.fields.publish_time.label')"
type="datetime-local"
name="publishTime"
@input="onPublishTimeChange"
@ -442,13 +452,13 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.template"
:options="templates"
label="自定义模板"
:label="$t('core.page.settings.fields.template.label')"
type="select"
name="template"
></FormKit>
<FormKit
v-model="formState.spec.cover"
label="封面图"
:label="$t('core.page.settings.fields.cover.label')"
type="attachment"
name="cover"
validation="length:0,1024"
@ -465,7 +475,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.page.settings.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -490,7 +502,7 @@ const { handleGenerateSlug } = useSlugify(
type="secondary"
@click="handlePublishClick()"
>
发布
{{ $t("core.common.buttons.publish") }}
</VButton>
<VButton
v-else
@ -498,13 +510,15 @@ const { handleGenerateSlug } = useSlugify(
type="danger"
@click="handleUnpublish()"
>
取消发布
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
</template>
<VButton :loading="saving" type="secondary" @click="handleSaveClick">
保存
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton type="default" @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
<VButton type="default" @click="onVisibleChange(false)"> </VButton>
</VSpace>
</template>
</VModal>

View File

@ -21,11 +21,11 @@ export default definePlugin({
name: "SinglePages",
component: SinglePageList,
meta: {
title: "页面",
title: "core.page.title",
searchable: true,
permissions: ["system:singlepages:view"],
menu: {
name: "页面",
name: "core.sidebar.menu.items.single_pages",
group: "content",
icon: markRaw(IconPages),
priority: 1,
@ -38,7 +38,7 @@ export default definePlugin({
name: "DeletedSinglePages",
component: DeletedSinglePageList,
meta: {
title: "页面回收站",
title: "core.deleted_page.title",
searchable: true,
permissions: ["system:singlepages:view"],
},
@ -48,7 +48,7 @@ export default definePlugin({
name: "SinglePageEditor",
component: SinglePageEditor,
meta: {
title: "页面编辑",
title: "core.page_editor.title",
searchable: true,
permissions: ["system:singlepages:manage"],
},

View File

@ -31,7 +31,9 @@ onMounted(handleFetchSinglePages);
</span>
<div>
<span class="text-sm text-gray-500">页面</span>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.page_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ singlePageTotal || 0 }}
</p>

View File

@ -27,8 +27,10 @@ import cloneDeep from "lodash.clonedeep";
import { getNode } from "@formkit/core";
import FilterTag from "@/components/filter/FilterTag.vue";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
@ -86,25 +88,29 @@ const handleCheckAllChange = (e: Event) => {
const handleDeletePermanently = async (post: Post) => {
Dialog.warning({
title: "确定要永久删除该文章吗?",
description: "删除之后将无法恢复",
title: t("core.deleted_post.operations.delete.title"),
description: t("core.deleted_post.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name: post.metadata.name,
});
await refetch();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: "确定要永久删除选中的文章吗?",
description: "删除之后将无法恢复",
title: t("core.deleted_post.operations.delete_in_batch.title"),
description: t("core.deleted_post.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
@ -116,15 +122,17 @@ const handleDeletePermanentlyInBatch = async () => {
await refetch();
selectedPostNames.value = [];
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleRecovery = async (post: Post) => {
Dialog.warning({
title: "确定要恢复该文章吗?",
description: "该操作会将文章恢复到被删除之前的状态",
title: t("core.deleted_post.operations.recovery.title"),
description: t("core.deleted_post.operations.recovery.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
const postToUpdate = cloneDeep(post);
postToUpdate.spec.deleted = false;
@ -135,15 +143,19 @@ const handleRecovery = async (post: Post) => {
await refetch();
Toast.success("恢复成功");
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: "确定要恢复选中的文章吗?",
description: "该操作会将文章恢复到被删除之前的状态",
title: t("core.deleted_post.operations.recovery_in_batch.title"),
description: t(
"core.deleted_post.operations.recovery_in_batch.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
@ -170,7 +182,7 @@ const handleRecoveryInBatch = async () => {
await refetch();
selectedPostNames.value = [];
Toast.success("恢复成功");
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
@ -193,13 +205,15 @@ function handleClearKeyword() {
}
</script>
<template>
<VPageHeader title="文章回收站">
<VPageHeader :title="$t('core.deleted_post.title')">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Posts' }" size="sm">返回</VButton>
<VButton :route="{ name: 'Posts' }" size="sm">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
@ -208,7 +222,7 @@ function handleClearKeyword() {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -240,7 +254,7 @@ function handleClearKeyword() {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -248,15 +262,19 @@ function handleClearKeyword() {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
永久删除
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
恢复
{{ $t("core.common.buttons.recovery") }}
</VButton>
</VSpace>
</div>
@ -283,14 +301,16 @@ function handleClearKeyword() {
<Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty
message="你可以尝试刷新或者返回文章管理"
title="没有文章被放入回收站"
:message="$t('core.deleted_post.empty.message')"
:title="$t('core.deleted_post.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch"></VButton>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton :route="{ name: 'Posts' }" type="primary">
返回
{{ $t("core.common.buttons.back") }}
</VButton>
</VSpace>
</template>
@ -325,7 +345,8 @@ function handleClearKeyword() {
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
分类<span
{{ $t("core.post.list.fields.categories") }}
<span
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
class="cursor-pointer hover:text-gray-900"
@ -334,10 +355,18 @@ function handleClearKeyword() {
</span>
</p>
<span class="text-xs text-gray-500">
访问量 {{ post.stats.visit || 0 }}
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment || 0 }}
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
@ -378,12 +407,20 @@ function handleClearKeyword() {
</VEntityField>
<VEntityField v-if="!post?.post?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="success" animate />
<VStatusDot
v-tooltip="$t('core.common.tooltips.recovering')"
state="success"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="post?.post?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -404,7 +441,7 @@ function handleClearKeyword() {
type="danger"
@click="handleDeletePermanently(post.post)"
>
永久删除
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton
v-close-popper
@ -412,7 +449,7 @@ function handleClearKeyword() {
type="default"
@click="handleRecovery(post.post)"
>
恢复
{{ $t("core.common.buttons.recovery") }}
</VButton>
</template>
</VEntity>
@ -425,6 +462,8 @@ function handleClearKeyword() {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[20, 30, 50, 100]"
/>

View File

@ -11,7 +11,6 @@ import {
Dialog,
} from "@halo-dev/components";
import PostSettingModal from "./components/PostSettingModal.vue";
import PostPreviewModal from "./components/PostPreviewModal.vue";
import type { Post, PostRequest } from "@halo-dev/api-client";
import {
computed,
@ -34,8 +33,10 @@ import {
} from "@/composables/use-editor-extension-points";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";
const router = useRouter();
const { t } = useI18n();
// Editor providers
const { editorProviders } = useEditorExtensionPoints();
@ -90,7 +91,6 @@ const initialFormState: PostRequest = {
const formState = ref<PostRequest>(cloneDeep(initialFormState));
const settingModal = ref(false);
const previewModal = ref(false);
const saving = ref(false);
const publishing = ref(false);
@ -118,7 +118,7 @@ const handleSave = async () => {
// Set default title and slug
if (!formState.value.post.spec.title) {
formState.value.post.spec.title = "无标题文章";
formState.value.post.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.post.spec.slug) {
@ -140,12 +140,12 @@ const handleSave = async () => {
name.value = data.metadata.name;
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
handleClearCache(name.value as string);
await handleFetchContent();
} catch (e) {
console.error("Failed to save post", e);
Toast.error("保存失败,请重试");
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
saving.value = false;
}
@ -187,11 +187,13 @@ const handlePublish = async () => {
router.push({ name: "Posts" });
}
Toast.success("发布成功", { duration: 2000 });
Toast.success(t("core.common.toast.publish_success"), {
duration: 2000,
});
handleClearCache(name.value as string);
} catch (error) {
console.error("Failed to publish post", error);
Toast.error("发布失败,请重试");
Toast.error(t("core.common.toast.publish_failed_and_retry"));
} finally {
publishing.value = false;
}
@ -249,8 +251,12 @@ const handleFetchContent = async () => {
formState.value.post = data;
} else {
Dialog.warning({
title: "警告",
description: `未找到符合 ${data.rawType} 格式的编辑器,请检查是否已安装编辑器插件`,
title: t("core.common.dialog.titles.warning"),
description: t("core.common.dialog.descriptions.editor_not_found", {
raw_type: data.rawType,
}),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: () => {
router.back();
},
@ -341,8 +347,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<PostPreviewModal v-model:visible="previewModal" :post="formState.post" />
<VPageHeader title="文章">
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
@ -353,21 +358,11 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
:provider="currentEditorProvider"
@select="handleChangeEditorProvider"
/>
<!-- TODO: add preview post support -->
<VButton
v-if="false"
size="sm"
type="default"
@click="previewModal = true"
>
预览
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />
</template>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton
v-if="isUpdateMode"
@ -378,7 +373,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
<template #icon>
<IconSettings class="h-full w-full" />
</template>
设置
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
type="secondary"
@ -388,7 +383,7 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
发布
{{ $t("core.common.buttons.publish") }}
</VButton>
</VSpace>
</template>

View File

@ -7,7 +7,6 @@ import {
IconBookRead,
IconEye,
IconEyeOff,
IconTeam,
IconRefreshLine,
IconExternalLinkLine,
Dialog,
@ -41,12 +40,14 @@ import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { postLabels } from "@/constants/labels";
import FilterTag from "@/components/filter/FilterTag.vue";
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core";
import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const settingModal = ref(false);
const selectedPost = ref<Post>();
@ -59,7 +60,7 @@ interface VisibleItem {
value?: "PUBLIC" | "INTERNAL" | "PRIVATE";
}
interface PublishStatuItem {
interface PublishStatusItem {
label: string;
value?: boolean;
}
@ -72,64 +73,59 @@ interface SortItem {
const VisibleItems: VisibleItem[] = [
{
label: "全部",
label: t("core.post.filters.visible.items.all"),
value: undefined,
},
{
label: "公开",
label: t("core.post.filters.visible.items.public"),
value: "PUBLIC",
},
// TODO: 访
// {
// label: "访",
// value: "INTERNAL",
// },
{
label: "私有",
label: t("core.post.filters.visible.items.private"),
value: "PRIVATE",
},
];
const PublishStatuItems: PublishStatuItem[] = [
const PublishStatusItems: PublishStatusItem[] = [
{
label: "全部",
label: t("core.post.filters.status.items.all"),
value: undefined,
},
{
label: "已发布",
label: t("core.post.filters.status.items.published"),
value: true,
},
{
label: "未发布",
label: t("core.post.filters.status.items.draft"),
value: false,
},
];
const SortItems: SortItem[] = [
{
label: "较近发布",
label: t("core.post.filters.sort.items.publish_time_desc"),
sort: "PUBLISH_TIME",
sortOrder: false,
},
{
label: "较早发布",
label: t("core.post.filters.sort.items.publish_time_asc"),
sort: "PUBLISH_TIME",
sortOrder: true,
},
{
label: "较近创建",
label: t("core.post.filters.sort.items.create_time_desc"),
sort: "CREATE_TIME",
sortOrder: false,
},
{
label: "较早创建",
label: t("core.post.filters.sort.items.create_time_asc"),
sort: "CREATE_TIME",
sortOrder: true,
},
];
const selectedVisibleItem = ref<VisibleItem>(VisibleItems[0]);
const selectedPublishStatusItem = ref<PublishStatuItem>(PublishStatuItems[0]);
const selectedPublishStatusItem = ref<PublishStatusItem>(PublishStatusItems[0]);
const selectedSortItem = ref<SortItem>();
const selectedCategory = ref<Category>();
const selectedTag = ref<Tag>();
@ -141,7 +137,7 @@ function handleVisibleItemChange(visibleItem: VisibleItem) {
page.value = 1;
}
function handlePublishStatusItemChange(publishStatusItem: PublishStatuItem) {
function handlePublishStatusItemChange(publishStatusItem: PublishStatusItem) {
selectedPublishStatusItem.value = publishStatusItem;
page.value = 1;
}
@ -181,7 +177,7 @@ function handleClearKeyword() {
function handleClearFilters() {
selectedVisibleItem.value = VisibleItems[0];
selectedPublishStatusItem.value = PublishStatuItems[0];
selectedPublishStatusItem.value = PublishStatusItems[0];
selectedSortItem.value = undefined;
selectedCategory.value = undefined;
selectedTag.value = undefined;
@ -351,7 +347,9 @@ const checkSelection = (post: Post) => {
const getPublishStatus = (post: Post) => {
const { labels } = post.metadata;
return labels?.[postLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
return labels?.[postLabels.PUBLISHED] === "true"
? t("core.post.filters.status.items.published")
: t("core.post.filters.status.items.draft");
};
const isPublishing = (post: Post) => {
@ -377,25 +375,29 @@ const handleCheckAllChange = (e: Event) => {
const handleDelete = async (post: Post) => {
Dialog.warning({
title: "确定要删除该文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
title: t("core.post.operations.delete.title"),
description: t("core.post.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.post.recyclePost({
name: post.metadata.name,
});
await refetch();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除选中的文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
title: t("core.post.operations.delete_in_batch.title"),
description: t("core.post.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
@ -407,7 +409,7 @@ const handleDeleteInBatch = async () => {
await refetch();
selectedPostNames.value = [];
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
@ -424,22 +426,28 @@ watch(selectedPostNames, (newValue) => {
>
<template #actions>
<span @click="handleSelectPrevious">
<IconArrowLeft v-tooltip="``" />
<IconArrowLeft v-tooltip="$t('core.common.buttons.previous')" />
</span>
<span @click="handleSelectNext">
<IconArrowRight v-tooltip="``" />
<IconArrowRight v-tooltip="$t('core.common.buttons.next')" />
</span>
</template>
</PostSettingModal>
<VPageHeader title="文章">
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
<VButton :route="{ name: 'DeletedPosts' }" size="sm">回收站</VButton>
<VButton :route="{ name: 'Categories' }" size="sm">
{{ $t("core.post.actions.categories") }}
</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">
{{ $t("core.post.actions.tags") }}
</VButton>
<VButton :route="{ name: 'DeletedPosts' }" size="sm">
{{ $t("core.post.actions.recycle_bin") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
@ -449,7 +457,7 @@ watch(selectedPostNames, (newValue) => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -481,7 +489,7 @@ watch(selectedPostNames, (newValue) => {
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -489,56 +497,84 @@ watch(selectedPostNames, (newValue) => {
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
<FilterTag
v-if="selectedPublishStatusItem.value !== undefined"
@close="handlePublishStatusItemChange(PublishStatuItems[0])"
@close="handlePublishStatusItemChange(PublishStatusItems[0])"
>
状态{{ selectedPublishStatusItem.label }}
{{
$t("core.common.filters.results.status", {
status: selectedPublishStatusItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedVisibleItem.value"
@close="handleVisibleItemChange(VisibleItems[0])"
>
可见性{{ selectedVisibleItem.label }}
{{
$t("core.post.filters.visible.result", {
visible: selectedVisibleItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedCategory"
@close="handleCategoryChange()"
>
分类{{ selectedCategory.spec.displayName }}
{{
$t("core.post.filters.category.result", {
category: selectedCategory.spec.displayName,
})
}}
</FilterTag>
<FilterTag v-if="selectedTag" @click="handleTagChange()">
标签{{ selectedTag.spec.displayName }}
{{
$t("core.post.filters.tag.result", {
tag: selectedTag.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedContributor"
@close="handleContributorChange()"
>
作者{{ selectedContributor.spec.displayName }}
{{
$t("core.post.filters.author.result", {
author: selectedContributor.spec.displayName,
})
}}
</FilterTag>
<FilterTag
v-if="selectedSortItem"
@close="handleSortItemChange()"
>
排序{{ selectedSortItem.label }}
{{
$t("core.common.filters.results.sort", {
sort: selectedSortItem.label,
})
}}
</FilterTag>
<FilteCleanButton
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
@ -548,7 +584,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">状态</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.status") }}
</span>
<span>
<IconArrowDown />
</span>
@ -557,7 +595,7 @@ watch(selectedPostNames, (newValue) => {
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(filterItem, index) in PublishStatuItems"
v-for="(filterItem, index) in PublishStatusItems"
:key="index"
v-close-popper
:class="{
@ -578,7 +616,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 可见性 </span>
<span class="mr-0.5">
{{ $t("core.post.filters.visible.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -612,7 +652,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">分类</span>
<span class="mr-0.5">
{{ $t("core.post.filters.category.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -625,7 +667,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">标签</span>
<span class="mr-0.5">
{{ $t("core.post.filters.tag.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -638,7 +682,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">作者</span>
<span class="mr-0.5">
{{ $t("core.post.filters.author.label") }}
</span>
<span>
<IconArrowDown />
</span>
@ -648,7 +694,9 @@ watch(selectedPostNames, (newValue) => {
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.sort") }}
</span>
<span>
<IconArrowDown />
</span>
@ -675,7 +723,7 @@ watch(selectedPostNames, (newValue) => {
@click="refetch()"
>
<IconRefreshLine
v-tooltip="`刷新`"
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
@ -688,10 +736,15 @@ watch(selectedPostNames, (newValue) => {
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建文章" title="当前没有文章">
<VEmpty
:message="$t('core.post.empty.message')"
:title="$t('core.post.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch"></VButton>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
@ -700,7 +753,7 @@ watch(selectedPostNames, (newValue) => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建文章
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -738,7 +791,9 @@ watch(selectedPostNames, (newValue) => {
<VSpace class="mt-1 sm:mt-0">
<RouterLink
v-if="post.post.status?.inProgress"
v-tooltip="`当前有内容已保存,但还未发布。`"
v-tooltip="
$t('core.common.tooltips.unpublished_content_tip')
"
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
@ -765,7 +820,8 @@ watch(selectedPostNames, (newValue) => {
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
分类<a
{{ $t("core.post.list.fields.categories") }}
<a
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
:href="category.status?.permalink"
@ -777,16 +833,24 @@ watch(selectedPostNames, (newValue) => {
</a>
</p>
<span class="text-xs text-gray-500">
访问量 {{ post.stats.visit || 0 }}
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment || 0 }}
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
<span
v-if="post.post.spec.pinned"
class="text-xs text-gray-500"
>
已置顶
{{ $t("core.post.list.fields.pinned") }}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
@ -827,32 +891,33 @@ watch(selectedPostNames, (newValue) => {
</VEntityField>
<VEntityField :description="getPublishStatus(post.post)">
<template v-if="isPublishing(post.post)" #description>
<VStatusDot text="发布中" animate />
<VStatusDot
:text="$t('core.common.tooltips.publishing')"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="`公开访问`"
v-tooltip="$t('core.post.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="`私有访问`"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<!-- TODO: 支持内部成员可访问 -->
<IconTeam
v-if="false"
v-tooltip="`内部成员可访问`"
v-tooltip="$t('core.post.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField v-if="post?.post?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -873,7 +938,7 @@ watch(selectedPostNames, (newValue) => {
type="secondary"
@click="handleOpenSettingModal(post.post)"
>
设置
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
v-close-popper
@ -881,7 +946,7 @@ watch(selectedPostNames, (newValue) => {
type="danger"
@click="handleDelete(post.post)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>
@ -894,6 +959,8 @@ watch(selectedPostNames, (newValue) => {
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[20, 30, 50, 100]"
/>

View File

@ -79,7 +79,7 @@ const onEditingModalClose = () => {
:category="selectedCategory"
@close="onEditingModalClose"
/>
<VPageHeader title="文章分类">
<VPageHeader :title="$t('core.post_category.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
@ -93,7 +93,7 @@ const onEditingModalClose = () => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
@ -106,7 +106,11 @@ const onEditingModalClose = () => {
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium">
{{ categories?.length || 0 }} 个分类
{{
$t("core.post_category.header.title", {
count: categories?.length || 0,
})
}}
</span>
</div>
</div>
@ -114,10 +118,15 @@ const onEditingModalClose = () => {
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!categories?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建分类" title="当前没有分类">
<VEmpty
:message="$t('core.post_category.empty.message')"
:title="$t('core.post_category.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchCategories"></VButton>
<VButton @click="handleFetchCategories">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
type="primary"
@ -126,7 +135,7 @@ const onEditingModalClose = () => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建分类
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>

View File

@ -23,6 +23,7 @@ import { setFocus } from "@/formkit/utils/focus";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -40,6 +41,8 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const initialFormState: Category = {
spec: {
displayName: "",
@ -67,7 +70,9 @@ const isUpdateMode = computed(() => {
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑文章分类" : "新增文章分类";
return isUpdateMode.value
? t("core.post_category.editing_modal.titles.update")
: t("core.post_category.editing_modal.titles.create");
});
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
@ -101,7 +106,7 @@ const handleSaveCategory = async () => {
}
onVisibleChange(false);
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create category", e);
} finally {
@ -178,7 +183,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 常规 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_category.editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -186,21 +193,27 @@ const { handleGenerateSlug } = useSlugify(
id="displayNameInput"
v-model="formState.spec.displayName"
name="displayName"
label="名称"
:label="
$t('core.post_category.editing_modal.fields.display_name.label')
"
type="text"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常用于生成分类的固定链接"
:help="$t('core.post_category.editing_modal.fields.slug.help')"
name="slug"
label="别名"
:label="$t('core.post_category.editing_modal.fields.slug.label')"
type="text"
validation="required|length:0,50"
>
<template #suffix>
<div
v-tooltip="'根据名称重新生成别名'"
v-tooltip="
$t(
'core.post_category.editing_modal.fields.slug.refresh_message'
)
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
@ -213,23 +226,29 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.template"
:options="templates"
label="自定义模板"
:label="
$t('core.post_category.editing_modal.fields.template.label')
"
type="select"
name="template"
></FormKit>
<FormKit
v-model="formState.spec.cover"
help="需要主题适配以支持"
:help="$t('core.post_category.editing_modal.fields.cover.help')"
name="cover"
label="封面图"
:label="$t('core.post_category.editing_modal.fields.cover.label')"
type="attachment"
validation="length:0,1024"
></FormKit>
<FormKit
v-model="formState.spec.description"
name="description"
help="需要主题适配以支持"
label="描述"
:help="
$t('core.post_category.editing_modal.fields.description.help')
"
:label="
$t('core.post_category.editing_modal.fields.description.label')
"
type="textarea"
validation="length:0,200"
></FormKit>
@ -245,7 +264,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_category.editing_modal.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -265,10 +286,13 @@ const { handleGenerateSlug } = useSlugify(
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('category-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -85,11 +85,19 @@ function onDelete(category: CategoryTree) {
<template #end>
<VEntityField v-if="category.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
:description="`${category.status?.postCount || 0} 篇文章`"
:description="
$t('core.common.fields.post_count', {
count: category.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
@ -110,7 +118,7 @@ function onDelete(category: CategoryTree) {
type="secondary"
@click="onOpenEditingModal(category)"
>
修改
{{ $t("core.common.buttons.edit") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
@ -119,7 +127,7 @@ function onDelete(category: CategoryTree) {
type="danger"
@click="onDelete(category)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>

View File

@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import CategoryEditingModal from "../CategoryEditingModal.vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import messages from "@intlify/unplugin-vue-i18n/messages";
describe("CategoryEditingModal", function () {
beforeEach(() => {
@ -9,6 +11,18 @@ describe("CategoryEditingModal", function () {
});
it("should render", function () {
expect(mount(CategoryEditingModal)).toBeDefined();
expect(
mount(CategoryEditingModal, {
global: {
plugins: [
createI18n({
legacy: false,
locale: "en",
messages,
}),
],
},
})
).toBeDefined();
});
});

View File

@ -6,6 +6,7 @@ import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
import { buildCategoriesTree } from "@/modules/contents/posts/categories/utils";
import { Dialog, Toast } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface usePostCategoryReturn {
categories: Ref<Category[] | undefined>;
@ -16,6 +17,8 @@ interface usePostCategoryReturn {
}
export function usePostCategory(): usePostCategoryReturn {
const { t } = useI18n();
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
const {
@ -48,9 +51,11 @@ export function usePostCategory(): usePostCategoryReturn {
const handleDelete = async (category: CategoryTree) => {
Dialog.warning({
title: "确定要删除该分类吗?",
description: "删除此分类之后,对应文章的关联将被解除。该操作不可恢复。",
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.category.deletecontentHaloRunV1alpha1Category(
@ -59,7 +64,7 @@ export function usePostCategory(): usePostCategoryReturn {
}
);
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete tag", e);
} finally {

View File

@ -1,55 +0,0 @@
<script lang="ts" setup>
import type { Post } from "@halo-dev/api-client";
import { VModal, IconLink } from "@halo-dev/components";
withDefaults(
defineProps<{
visible: boolean;
post?: Post | null;
}>(),
{
visible: false,
post: null,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
</script>
<template>
<VModal
:body-class="['!p-0']"
:visible="visible"
:layer-closable="true"
fullscreen
title="文章预览"
@update:visible="onVisibleChange"
>
<template #actions>
<span>
<a
href="https://halo.run/archives/halo-154-released.html"
target="_blank"
>
<IconLink />
</a>
</span>
</template>
<div class="flex h-full items-center justify-center">
<iframe
v-if="visible"
class="h-full w-full border-none transition-all duration-300"
src="https://halo.run/archives/halo-154-released.html"
></iframe>
</div>
</VModal>
</template>

View File

@ -18,6 +18,7 @@ import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { submitForm } from "@formkit/core";
import useSlugify from "@/composables/use-slugify";
import { useMutation } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const initialFormState: Post = {
spec: {
@ -69,6 +70,8 @@ const emit = defineEmits<{
(event: "published", post: Post): void;
}>();
const { t } = useI18n();
const formState = ref<Post>(cloneDeep(initialFormState));
const saving = ref(false);
const publishing = ref(false);
@ -146,7 +149,7 @@ const { mutateAsync: postUpdateMutate } = useMutation({
retry: 3,
onError: (error) => {
console.error("Failed to update post", error);
Toast.error(`服务器内部错误`);
Toast.error(t("core.common.toast.server_internal_error"));
},
});
@ -185,7 +188,7 @@ const handleSave = async () => {
handleVisibleChange(false);
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to save post", e);
} finally {
@ -212,7 +215,7 @@ const handlePublish = async () => {
handleVisibleChange(false);
Toast.success("发布成功");
Toast.success(t("core.common.toast.publish_success"));
} catch (e) {
console.error("Failed to publish post", e);
} finally {
@ -230,7 +233,7 @@ const handleUnpublish = async () => {
handleVisibleChange(false);
Toast.success("取消发布成功");
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (e) {
console.error("Failed to publish post", e);
} finally {
@ -280,7 +283,7 @@ const { handleGenerateSlug } = useSlugify(
<VModal
:visible="visible"
:width="700"
title="文章设置"
:title="$t('core.post.settings.title')"
:centered="false"
@update:visible="handleVisibleChange"
>
@ -300,29 +303,31 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
常规设置
{{ $t("core.post.settings.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.title"
label="标题"
:label="$t('core.post.settings.fields.title.label')"
type="text"
name="title"
validation="required|length:0,100"
></FormKit>
<FormKit
v-model="formState.spec.slug"
label="别名"
:label="$t('core.post.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
help="通常用于生成文章的固定链接"
:help="$t('core.post.settings.fields.slug.help')"
>
<template #suffix>
<div
v-tooltip="'根据标题重新生成别名'"
v-tooltip="
$t('core.post.settings.fields.slug.refresh_message')
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
@ -334,14 +339,14 @@ const { handleGenerateSlug } = useSlugify(
</FormKit>
<FormKit
v-model="formState.spec.categories"
label="分类目录"
:label="$t('core.post.settings.fields.categories.label')"
name="categories"
type="categorySelect"
:multiple="true"
/>
<FormKit
v-model="formState.spec.tags"
label="标签"
:label="$t('core.post.settings.fields.tags.label')"
name="tags"
type="tagSelect"
:multiple="true"
@ -349,18 +354,20 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.excerpt.autoGenerate"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="autoGenerate"
label="自动生成摘要"
:label="
$t('core.post.settings.fields.auto_generate_excerpt.label')
"
type="radio"
>
</FormKit>
<FormKit
v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.spec.excerpt.raw"
label="自定义摘要"
:label="$t('core.post.settings.fields.raw_excerpt.label')"
name="raw"
type="textarea"
:rows="5"
@ -377,7 +384,7 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
高级设置
{{ $t("core.post.settings.groups.advanced") }}
</span>
</div>
</div>
@ -385,49 +392,52 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.allowComment"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
label="允许评论"
:label="$t('core.post.settings.fields.allow_comment.label')"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.pinned"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
label="是否置顶"
:label="$t('core.post.settings.fields.pinned.label')"
name="pinned"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.visible"
:options="[
{ label: '公开', value: 'PUBLIC' },
{ label: '私有', value: 'PRIVATE' },
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
label="可见性"
:label="$t('core.post.settings.fields.visible.label')"
name="visible"
type="select"
></FormKit>
<FormKit
:model-value="publishTime"
label="发表时间"
:label="$t('core.post.settings.fields.publish_time.label')"
type="datetime-local"
@input="onPublishTimeChange"
></FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"
label="自定义模板"
:label="$t('core.post.settings.fields.template.label')"
name="template"
type="select"
></FormKit>
<FormKit
v-model="formState.spec.cover"
name="cover"
label="封面图"
:label="$t('core.post.settings.fields.cover.label')"
type="attachment"
validation="length:0,1024"
></FormKit>
@ -443,7 +453,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -466,7 +478,7 @@ const { handleGenerateSlug } = useSlugify(
type="secondary"
@click="handlePublishClick()"
>
发布
{{ $t("core.common.buttons.publish") }}
</VButton>
<VButton
v-else
@ -474,14 +486,14 @@ const { handleGenerateSlug } = useSlugify(
type="danger"
@click="handleUnpublish()"
>
取消发布
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
</template>
<VButton :loading="saving" type="secondary" @click="handleSaveClick()">
保存
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton type="default" @click="handleVisibleChange(false)">
关闭
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
</template>

View File

@ -3,6 +3,8 @@ import { mount } from "@vue/test-utils";
import PostSettingModal from "../PostSettingModal.vue";
import { createPinia, setActivePinia } from "pinia";
import { VueQueryPlugin } from "@tanstack/vue-query";
import { createI18n } from "vue-i18n";
import messages from "@intlify/unplugin-vue-i18n/messages";
describe("PostSettingModal", () => {
beforeEach(() => {
@ -19,7 +21,14 @@ describe("PostSettingModal", () => {
},
{
global: {
plugins: [VueQueryPlugin],
plugins: [
VueQueryPlugin,
createI18n({
legacy: false,
locale: "en",
messages,
}),
],
},
}
);

View File

@ -26,11 +26,11 @@ export default definePlugin({
name: "Posts",
component: PostList,
meta: {
title: "文章",
title: "core.post.title",
searchable: true,
permissions: ["system:posts:view"],
menu: {
name: "文章",
name: "core.sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
@ -43,7 +43,7 @@ export default definePlugin({
name: "DeletedPosts",
component: DeletedPostList,
meta: {
title: "文章回收站",
title: "core.deleted_post.title",
searchable: true,
permissions: ["system:posts:view"],
},
@ -53,7 +53,7 @@ export default definePlugin({
name: "PostEditor",
component: PostEditor,
meta: {
title: "文章编辑",
title: "core.post_editor.title",
searchable: true,
permissions: ["system:posts:manage"],
},
@ -67,7 +67,7 @@ export default definePlugin({
name: "Categories",
component: CategoryList,
meta: {
title: "文章分类",
title: "core.post_category.title",
searchable: true,
permissions: ["system:posts:view"],
},
@ -83,7 +83,7 @@ export default definePlugin({
name: "Tags",
component: TagList,
meta: {
title: "文章标签",
title: "core.post_tag.title",
searchable: true,
permissions: ["system:posts:view"],
},

View File

@ -116,7 +116,7 @@ onMounted(async () => {
@next="handleSelectNext"
@previous="handleSelectPrevious"
/>
<VPageHeader title="文章标签">
<VPageHeader :title="$t('core.post_tag.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
@ -129,7 +129,7 @@ onMounted(async () => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
@ -142,7 +142,9 @@ onMounted(async () => {
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium">
{{ tags?.length || 0 }} 个标签
{{
$t("core.post_tag.header.title", { count: tags?.length || 0 })
}}
</span>
</div>
<div class="flex flex-row gap-2">
@ -163,15 +165,20 @@ onMounted(async () => {
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!tags?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建标签" title="当前没有标签">
<VEmpty
:message="$t('core.post_tag.empty.message')"
:title="$t('core.post_tag.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchTags"></VButton>
<VButton @click="handleFetchTags">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="primary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建标签
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
@ -210,14 +217,18 @@ onMounted(async () => {
<VEntityField v-if="tag.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="`删除中`"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
:description="`${tag.status?.postCount || 0} 篇文章`"
:description="
$t('core.common.fields.post_count', {
count: tag.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
@ -238,7 +249,7 @@ onMounted(async () => {
type="secondary"
@click="handleOpenEditingModal(tag)"
>
修改
{{ $t("core.common.buttons.edit") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
@ -247,7 +258,7 @@ onMounted(async () => {
type="danger"
@click="handleDelete(tag)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>

View File

@ -24,6 +24,7 @@ import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -43,6 +44,8 @@ const emit = defineEmits<{
(event: "next"): void;
}>();
const { t } = useI18n();
const initialFormState: Tag = {
spec: {
displayName: "",
@ -66,7 +69,9 @@ const isUpdateMode = computed(() => {
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑文章标签" : "新增文章标签";
return isUpdateMode.value
? t("core.post_tag.editing_modal.titles.update")
: t("core.post_tag.editing_modal.titles.create");
});
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
@ -100,7 +105,7 @@ const handleSaveTag = async () => {
}
onVisibleChange(false);
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create tag", e);
} finally {
@ -183,7 +188,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 常规 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_tag.editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -191,21 +198,27 @@ const { handleGenerateSlug } = useSlugify(
id="displayNameInput"
v-model="formState.spec.displayName"
name="displayName"
label="名称"
:label="
$t('core.post_tag.editing_modal.fields.display_name.label')
"
type="text"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常用于生成标签的固定链接"
label="别名"
:help="$t('core.post_tag.editing_modal.fields.slug.help')"
:label="$t('core.post_tag.editing_modal.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,50"
>
<template #suffix>
<div
v-tooltip="'根据名称重新生成别名'"
v-tooltip="
$t(
'core.post_tag.editing_modal.fields.slug.refresh_message'
)
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
@ -218,16 +231,16 @@ const { handleGenerateSlug } = useSlugify(
<FormKit
v-model="formState.spec.color"
name="color"
help="需要主题适配以支持"
label="颜色"
:help="$t('core.post_tag.editing_modal.fields.color.help')"
:label="$t('core.post_tag.editing_modal.fields.color.label')"
type="color"
validation="length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.cover"
name="cover"
help="需要主题适配以支持"
label="封面图"
:help="$t('core.post_tag.editing_modal.fields.cover.help')"
:label="$t('core.post_tag.editing_modal.fields.cover.label')"
type="attachment"
validation="length:0,1024"
></FormKit>
@ -243,7 +256,9 @@ const { handleGenerateSlug } = useSlugify(
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_tag.editing_modal.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -263,10 +278,13 @@ const { handleGenerateSlug } = useSlugify(
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('tag-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -3,6 +3,7 @@ import type { Tag } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { Dialog, Toast } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface usePostTagReturn {
tags: Ref<Tag[] | undefined>;
@ -12,6 +13,8 @@ interface usePostTagReturn {
}
export function usePostTag(): usePostTagReturn {
const { t } = useI18n();
const {
data: tags,
isLoading,
@ -38,16 +41,18 @@ export function usePostTag(): usePostTagReturn {
const handleDelete = async (tag: Tag) => {
Dialog.warning({
title: "确定要删除该标签吗?",
description: "删除此标签之后,对应文章的关联将被解除。该操作不可恢复。",
title: t("core.post_tag.operations.delete.title"),
description: t("core.post_tag.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.tag.deletecontentHaloRunV1alpha1Tag({
name: tag.metadata.name,
});
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete tag", e);
} finally {

View File

@ -16,7 +16,9 @@ const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</span>
<div>
<span class="text-sm text-gray-500">文章</span>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.post_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.posts || 0 }}
</p>

View File

@ -34,7 +34,7 @@ const { data } = useQuery<ListedPost[]>({
<VCard
:body-class="['h-full', '!p-0', 'overflow-y-auto']"
class="h-full"
title="最近文章"
:title="$t('core.dashboard.widgets.presets.recent_published.title')"
>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(post, index) in data" :key="index">
@ -50,10 +50,20 @@ const { data } = useQuery<ListedPost[]>({
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
访问量 {{ post.stats.visit || 0 }}
{{
$t(
"core.dashboard.widgets.presets.recent_published.visits",
{ visits: post.stats.visit || 0 }
)
}}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment || 0 }}
{{
$t(
"core.dashboard.widgets.presets.recent_published.comments",
{ comments: post.stats.totalComment || 0 }
)
}}
</span>
</VSpace>
</template>

View File

@ -1,5 +1,5 @@
<template>
<VPageHeader title="仪表盘">
<VPageHeader :title="$t('core.dashboard.title')">
<template #icon>
<IconDashboard class="mr-2 self-center" />
</template>
@ -9,14 +9,18 @@
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
添加组件
{{ $t("core.dashboard.actions.add_widget") }}
</VButton>
<VButton type="secondary" @click="settings = !settings">
<template #icon>
<IconSettings v-if="!settings" class="h-full w-full" />
<IconSave v-else class="h-full w-full" />
</template>
{{ settings ? "完成" : "设置" }}
{{
settings
? $t("core.dashboard.actions.done")
: $t("core.dashboard.actions.setting")
}}
</VButton>
</VSpace>
</template>
@ -59,7 +63,7 @@
height="calc(100vh - 20px)"
:width="1280"
:layer-closable="true"
title="小组件"
:title="$t('core.dashboard.widgets.modal_title')"
>
<VTabbar
v-model:active-id="activeId"
@ -120,11 +124,14 @@ import { useStorage } from "@vueuse/core";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import type { DashboardStats } from "@halo-dev/api-client/index";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const widgetsGroup = [
{
id: "post",
label: "文章",
label: t("core.dashboard.widgets.groups.post"),
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "PostStatsWidget" },
{ x: 0, y: 0, w: 6, h: 10, i: 1, widget: "RecentPublishedWidget" },
@ -132,19 +139,19 @@ const widgetsGroup = [
},
{
id: "page",
label: "页面",
label: t("core.dashboard.widgets.groups.page"),
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "SinglePageStatsWidget" },
],
},
{
id: "comment",
label: "评论",
label: t("core.dashboard.widgets.groups.comment"),
widgets: [{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "CommentStatsWidget" }],
},
{
id: "user",
label: "用户",
label: t("core.dashboard.widgets.groups.user"),
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "UserStatsWidget" },
{ x: 0, y: 0, w: 3, h: 3, i: 1, widget: "UserProfileWidget" },
@ -152,7 +159,7 @@ const widgetsGroup = [
},
{
id: "other",
label: "其他",
label: t("core.dashboard.widgets.groups.other"),
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "ViewsStatsWidget" },
{ x: 0, y: 0, w: 6, h: 10, i: 1, widget: "QuickLinkWidget" },

View File

@ -24,10 +24,10 @@ export default definePlugin({
name: "Dashboard",
component: Dashboard,
meta: {
title: "仪表盘",
title: "core.dashboard.title",
searchable: true,
menu: {
name: "仪表盘",
name: "core.sidebar.menu.items.dashboard",
group: "dashboard",
icon: markRaw(IconDashboard),
priority: 0,

View File

@ -18,6 +18,7 @@ import { markRaw, ref, type Component } from "vue";
import { useRouter } from "vue-router";
import ThemePreviewModal from "@/modules/interface/themes/components/preview/ThemePreviewModal.vue";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
interface Action {
icon: Component;
@ -30,10 +31,14 @@ const router = useRouter();
const themePreviewVisible = ref(false);
const { t } = useI18n();
const actions: Action[] = [
{
icon: markRaw(IconUserLine),
title: "个人资料",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.user_profile.title"
),
action: () => {
router.push({
name: "UserDetail",
@ -43,7 +48,9 @@ const actions: Action[] = [
},
{
icon: markRaw(IconWindowLine),
title: "查看站点",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.view_site.title"
),
action: () => {
themePreviewVisible.value = true;
},
@ -51,7 +58,7 @@ const actions: Action[] = [
},
{
icon: markRaw(IconBookRead),
title: "创建文章",
title: t("core.dashboard.widgets.presets.quicklink.actions.new_post.title"),
action: () => {
router.push({
name: "PostEditor",
@ -61,7 +68,7 @@ const actions: Action[] = [
},
{
icon: markRaw(IconPages),
title: "创建页面",
title: t("core.dashboard.widgets.presets.quicklink.actions.new_page.title"),
action: () => {
router.push({
name: "SinglePageEditor",
@ -71,7 +78,9 @@ const actions: Action[] = [
},
{
icon: markRaw(IconFolder),
title: "附件上传",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.upload_attachment.title"
),
action: () => {
router.push({
name: "Attachments",
@ -84,7 +93,9 @@ const actions: Action[] = [
},
{
icon: markRaw(IconPalette),
title: "主题管理",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.theme_manage.title"
),
action: () => {
router.push({
name: "ThemeDetail",
@ -94,7 +105,9 @@ const actions: Action[] = [
},
{
icon: markRaw(IconPlug),
title: "插件管理",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.plugin_manage.title"
),
action: () => {
router.push({
name: "Plugins",
@ -104,7 +117,7 @@ const actions: Action[] = [
},
{
icon: markRaw(IconUserSettings),
title: "新建用户",
title: t("core.dashboard.widgets.presets.quicklink.actions.new_user.title"),
action: () => {
router.push({
name: "Users",
@ -117,14 +130,26 @@ const actions: Action[] = [
},
{
icon: markRaw(IconSearch),
title: "刷新搜索引擎",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.title"
),
action: () => {
Dialog.warning({
title: "确定要刷新搜索引擎索引吗?",
description: "此操作会对所有已发布的文章重新创建搜索引擎索引。",
title: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.dialog_title"
),
description: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.dialog_content"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.indices.buildPostIndices();
Toast.success("刷新成功");
Toast.success(
t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.success_message"
)
);
},
});
},
@ -136,7 +161,7 @@ const actions: Action[] = [
<VCard
:body-class="['h-full', 'overflow-y-auto', '@container']"
class="h-full"
title="快捷访问"
:title="$t('core.dashboard.widgets.presets.quicklink.title')"
>
<div
class="grid grid-cols-1 gap-2 overflow-hidden @sm:grid-cols-2 @md:grid-cols-3"
@ -169,5 +194,10 @@ const actions: Action[] = [
</div>
</div>
</VCard>
<ThemePreviewModal v-model:visible="themePreviewVisible" title="查看站点" />
<ThemePreviewModal
v-model:visible="themePreviewVisible"
:title="
$t('core.dashboard.widgets.presets.quicklink.actions.view_site.title')
"
/>
</template>

View File

@ -16,7 +16,9 @@ const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</span>
<div>
<span class="text-sm text-gray-500">浏览量</span>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.views_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.visits || 0 }}
</p>

View File

@ -28,6 +28,9 @@ import {
} from "./utils";
import { useDebounceFn } from "@vueuse/core";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const menuItems = ref<MenuItem[]>([] as MenuItem[]);
const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]);
@ -154,9 +157,11 @@ const handleUpdateInBatch = useDebounceFn(async () => {
const handleDelete = async (menuItem: MenuTreeItem) => {
Dialog.info({
title: "确定要删除该菜单项吗?",
description: "将同时删除所有子菜单项,删除后将无法恢复",
title: t("core.menu.operations.delete_menu_item.title"),
description: t("core.menu.operations.delete_menu_item.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.extension.menuItem.deletev1alpha1MenuItem({
name: menuItem.metadata.name,
@ -175,7 +180,7 @@ const handleDelete = async (menuItem: MenuTreeItem) => {
await handleFetchMenuItems();
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
},
});
};
@ -210,7 +215,7 @@ const handleResetMenuItems = async () => {
@close="onMenuItemEditingModalClose"
@saved="onMenuItemSaved"
/>
<VPageHeader title="菜单">
<VPageHeader :title="$t('core.menu.title')">
<template #icon>
<IconListSettings class="mr-2 self-center" />
</template>
@ -244,7 +249,7 @@ const handleResetMenuItems = async () => {
type="default"
@click="menuItemEditingModal = true"
>
新增
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</div>
@ -254,12 +259,14 @@ const handleResetMenuItems = async () => {
<VLoading v-if="loading" />
<Transition v-else-if="!menuItems.length" appear name="fade">
<VEmpty
message="你可以尝试刷新或者新建菜单项"
title="当前没有菜单项"
:message="$t('core.menu.menu_item_empty.message')"
:title="$t('core.menu.menu_item_empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchMenuItems()"> </VButton>
<VButton @click="handleFetchMenuItems()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:menus:manage']"
type="primary"
@ -268,7 +275,7 @@ const handleResetMenuItems = async () => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新增菜单项
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>

View File

@ -7,6 +7,7 @@ import { apiClient } from "@/utils/api-client";
import { reset } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -25,6 +26,8 @@ const emit = defineEmits<{
(event: "created", menu: Menu): void;
}>();
const { t } = useI18n();
const initialFormState: Menu = {
spec: {
displayName: "",
@ -46,7 +49,9 @@ const isUpdateMode = computed(() => {
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑菜单" : "新增菜单";
return isUpdateMode.value
? t("core.menu.menu_editing_modal.titles.update")
: t("core.menu.menu_editing_modal.titles.create");
});
const handleCreateMenu = async () => {
@ -65,7 +70,7 @@ const handleCreateMenu = async () => {
}
onVisibleChange(false);
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create menu", e);
} finally {
@ -124,7 +129,7 @@ watch(
<FormKit
id="menuDisplayNameInput"
v-model="formState.spec.displayName"
label="菜单名称"
:label="$t('core.menu.menu_editing_modal.fields.display_name.label')"
type="text"
name="displayName"
validation="required|length:0,100"
@ -136,10 +141,13 @@ watch(
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('menu-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -8,6 +8,7 @@ import { reset } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { setFocus } from "@/formkit/utils/focus";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -30,6 +31,8 @@ const emit = defineEmits<{
(event: "saved", menuItem: MenuItem): void;
}>();
const { t } = useI18n();
const initialFormState: MenuItem = {
spec: {
displayName: "",
@ -55,7 +58,9 @@ const isUpdateMode = computed(() => {
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑菜单项" : "新增菜单项";
return isUpdateMode.value
? t("core.menu.menu_item_editing_modal.titles.update")
: t("core.menu.menu_item_editing_modal.titles.create");
});
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
@ -128,7 +133,7 @@ const handleSaveMenuItem = async () => {
emit("saved", data);
}
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create menu item", e);
} finally {
@ -202,10 +207,12 @@ const baseRef: Ref = {
const menuItemRefs: MenuItemRef[] = [
{
label: "自定义链接",
label: t(
"core.menu.menu_item_editing_modal.fields.ref_kind.options.custom"
),
},
{
label: "文章",
label: t("core.menu.menu_item_editing_modal.fields.ref_kind.options.post"),
inputType: "postSelect",
ref: {
...baseRef,
@ -213,7 +220,9 @@ const menuItemRefs: MenuItemRef[] = [
},
},
{
label: "自定义页面",
label: t(
"core.menu.menu_item_editing_modal.fields.ref_kind.options.single_page"
),
inputType: "singlePageSelect",
ref: {
...baseRef,
@ -221,7 +230,9 @@ const menuItemRefs: MenuItemRef[] = [
},
},
{
label: "分类",
label: t(
"core.menu.menu_item_editing_modal.fields.ref_kind.options.category"
),
inputType: "categorySelect",
ref: {
...baseRef,
@ -229,7 +240,7 @@ const menuItemRefs: MenuItemRef[] = [
},
},
{
label: "标签",
label: t("core.menu.menu_item_editing_modal.fields.ref_kind.options.tag"),
inputType: "tagSelect",
ref: {
...baseRef,
@ -277,15 +288,23 @@ const onMenuItemSourceChange = () => {
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 常规 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.menu.menu_item_editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-if="!isUpdateMode && menu && visible"
v-model="selectedParentMenuItem"
label="上级菜单项"
placeholder="选择上级菜单项"
:label="
$t('core.menu.menu_item_editing_modal.fields.parent.label')
"
:placeholder="
$t(
'core.menu.menu_item_editing_modal.fields.parent.placeholder'
)
"
type="menuItemSelect"
:menu-items="menu?.spec.menuItems || []"
/>
@ -294,7 +313,9 @@ const onMenuItemSourceChange = () => {
v-model="selectedRefKind"
:options="menuItemRefsMap"
:disabled="isUpdateMode"
label="类型"
:label="
$t('core.menu.menu_item_editing_modal.fields.ref_kind.label')
"
type="select"
@change="onMenuItemSourceChange"
/>
@ -303,7 +324,11 @@ const onMenuItemSourceChange = () => {
v-if="!selectedRefKind"
id="displayNameInput"
v-model="formState.spec.displayName"
label="名称"
:label="
$t(
'core.menu.menu_item_editing_modal.fields.display_name.label'
)
"
type="text"
name="displayName"
validation="required|length:0,100"
@ -312,7 +337,7 @@ const onMenuItemSourceChange = () => {
<FormKit
v-if="!selectedRefKind"
v-model="formState.spec.href"
label="链接地址"
:label="$t('core.menu.menu_item_editing_modal.fields.href.label')"
type="text"
name="href"
validation="required|length:0,1024"
@ -323,7 +348,12 @@ const onMenuItemSourceChange = () => {
:id="selectedRef.inputType"
:key="selectedRef.inputType"
v-model="selectedRefName"
:placeholder="`请选择${selectedRef.label}`"
:placeholder="
$t(
'core.menu.menu_item_editing_modal.fields.ref_kind.placeholder',
{ label: selectedRef.label }
)
"
:label="selectedRef.label"
:type="selectedRef.inputType"
validation="required"
@ -331,24 +361,34 @@ const onMenuItemSourceChange = () => {
<FormKit
v-model="formState.spec.target"
label="打开方式"
:label="
$t('core.menu.menu_item_editing_modal.fields.target.label')
"
type="select"
name="target"
:options="[
{
label: '当前窗口',
label: $t(
'core.menu.menu_item_editing_modal.fields.target.options.self'
),
value: '_self',
},
{
label: '新窗口',
label: $t(
'core.menu.menu_item_editing_modal.fields.target.options.blank'
),
value: '_blank',
},
{
label: '父窗口',
label: $t(
'core.menu.menu_item_editing_modal.fields.target.options.parent'
),
value: '_parent',
},
{
label: '顶级窗口',
label: $t(
'core.menu.menu_item_editing_modal.fields.target.options.top'
),
value: '_top',
},
]"
@ -365,7 +405,9 @@ const onMenuItemSourceChange = () => {
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
<span class="text-base font-medium text-gray-900">
{{ $t("core.menu.menu_item_editing_modal.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
@ -385,10 +427,13 @@ const onMenuItemSourceChange = () => {
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('menuitem-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>

View File

@ -11,8 +11,10 @@ import Draggable from "vuedraggable";
import { ref } from "vue";
import type { MenuTreeItem } from "@/modules/interface/menus/utils";
import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
withDefaults(
defineProps<{
@ -49,10 +51,14 @@ function onDelete(menuItem: MenuTreeItem) {
}
const TargetRef = {
Post: "文章",
SinglePage: "页面",
Category: "分类",
Tag: "标签",
Post: t("core.menu.menu_item_editing_modal.fields.ref_kind.options.post"),
SinglePage: t(
"core.menu.menu_item_editing_modal.fields.ref_kind.options.single_page"
),
Category: t(
"core.menu.menu_item_editing_modal.fields.ref_kind.options.category"
),
Tag: t("core.menu.menu_item_editing_modal.fields.ref_kind.options.tag"),
};
function getMenuItemRefDisplayName(menuItem: MenuTreeItem) {
@ -112,7 +118,11 @@ function getMenuItemRefDisplayName(menuItem: MenuTreeItem) {
<template #end>
<VEntityField v-if="menuItem.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
</template>
@ -126,7 +136,7 @@ function getMenuItemRefDisplayName(menuItem: MenuTreeItem) {
type="secondary"
@click="onOpenEditingModal(menuItem)"
>
修改
{{ $t("core.common.buttons.edit") }}
</VButton>
<VButton
v-close-popper
@ -134,7 +144,7 @@ function getMenuItemRefDisplayName(menuItem: MenuTreeItem) {
type="default"
@click="onOpenCreateByParentModal(menuItem)"
>
添加子菜单项
{{ $t("core.menu.operations.add_sub_menu_item.button") }}
</VButton>
<VButton
v-close-popper
@ -142,7 +152,7 @@ function getMenuItemRefDisplayName(menuItem: MenuTreeItem) {
type="danger"
@click="onDelete(menuItem)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>

View File

@ -19,8 +19,10 @@ import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -97,9 +99,11 @@ const handleSelect = (menu: Menu) => {
const handleDeleteMenu = async (menu: Menu) => {
Dialog.warning({
title: "确定要删除该菜单吗?",
description: "将同时删除该菜单下的所有菜单项,该操作不可恢复。",
title: t("core.menu.operations.delete_menu.title"),
description: t("core.menu.operations.delete_menu.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.menu.deletev1alpha1Menu({
@ -115,7 +119,7 @@ const handleDeleteMenu = async (menu: Menu) => {
await Promise.all(deleteItemsPromises);
Toast.success("删除成功");
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete menu", e);
} finally {
@ -185,7 +189,7 @@ const handleSetPrimaryMenu = async (menu: Menu) => {
}
await handleFetchPrimaryMenuName();
Toast.success("设置成功");
Toast.success(t("core.menu.operations.set_primary.toast_success"));
};
onMounted(handleFetchPrimaryMenuName);
@ -197,13 +201,18 @@ onMounted(handleFetchPrimaryMenuName);
@close="handleFetchMenus()"
@created="handleSelect"
/>
<VCard :body-class="['!p-0']" title="菜单">
<VCard :body-class="['!p-0']" :title="$t('core.menu.title')">
<VLoading v-if="loading" />
<Transition v-else-if="!menus.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建菜单" title="当前没有菜单">
<VEmpty
:message="$t('core.menu.empty.message')"
:title="$t('core.menu.empty.title')"
>
<template #actions>
<VSpace>
<VButton size="sm" @click="handleFetchMenus()"> </VButton>
<VButton size="sm" @click="handleFetchMenus()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
</VEmpty>
@ -221,17 +230,27 @@ onMounted(handleFetchPrimaryMenuName);
<template #start>
<VEntityField
:title="menu.spec?.displayName"
:description="`${menu.spec.menuItems?.length || 0} 个菜单项`"
:description="
$t('core.menu.list.fields.items_count', {
count: menu.spec.menuItems?.length || 0,
})
"
>
<template v-if="menu.metadata.name === primaryMenuName" #extra>
<VTag>主菜单</VTag>
<VTag>
{{ $t("core.menu.list.fields.primary") }}
</VTag>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="menu.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
</template>
@ -245,7 +264,7 @@ onMounted(handleFetchPrimaryMenuName);
type="secondary"
@click="handleSetPrimaryMenu(menu)"
>
设置为主菜单
{{ $t("core.menu.operations.set_primary.button") }}
</VButton>
<VButton
v-close-popper
@ -253,7 +272,7 @@ onMounted(handleFetchPrimaryMenuName);
type="default"
@click="handleOpenEditingModal(menu)"
>
修改
{{ $t("core.common.buttons.edit") }}
</VButton>
<VButton
v-close-popper
@ -261,7 +280,7 @@ onMounted(handleFetchPrimaryMenuName);
type="danger"
@click="handleDeleteMenu(menu)"
>
删除
{{ $t("core.common.buttons.delete") }}
</VButton>
</template>
</VEntity>
@ -270,7 +289,7 @@ onMounted(handleFetchPrimaryMenuName);
</Transition>
<template v-if="currentUserHasPermission(['system:menus:manage'])" #footer>
<VButton block type="secondary" @click="handleOpenEditingModal()">
新增
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VCard>

View File

@ -16,11 +16,11 @@ export default definePlugin({
name: "Menus",
component: Menus,
meta: {
title: "菜单",
title: "core.menu.title",
searchable: true,
permissions: ["system:menus:view"],
menu: {
name: "菜单",
name: "core.sidebar.menu.items.menus",
group: "interface",
icon: markRaw(IconListSettings),
priority: 1,

View File

@ -23,6 +23,9 @@ import type { Ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
const upgradeModal = ref(false);
@ -32,8 +35,10 @@ const { isActivated, getFailedMessage, handleResetSettingConfig } =
const handleReloadTheme = async () => {
Dialog.warning({
title: "确定要重载主题的所有配置吗?",
description: "该操作仅会重载主题配置和设置表单定义,不会删除已保存的配置。",
title: t("core.theme.operations.reload.title"),
description: t("core.theme.operations.reload.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (!selectedTheme?.value) {
@ -44,7 +49,7 @@ const handleReloadTheme = async () => {
name: selectedTheme.value.metadata.name as string,
});
Toast.success("重载配置成功");
Toast.success(t("core.theme.operations.reload.toast_success"));
window.location.reload();
} catch (e) {
@ -82,7 +87,11 @@ const onUpgradeModalClose = () => {
{{ selectedTheme?.spec.version }}
</span>
<VTag>
{{ isActivated ? "当前启用" : "未启用" }}
{{
isActivated
? t("core.common.status.activated")
: t("core.common.status.not_activated")
}}
</VTag>
<VStatusDot
v-if="getFailedMessage()"
@ -108,7 +117,7 @@ const onUpgradeModalClose = () => {
type="secondary"
@click="upgradeModal = true"
>
升级
{{ $t("core.common.buttons.upgrade") }}
</VButton>
<VButton
v-close-popper
@ -116,7 +125,7 @@ const onUpgradeModalClose = () => {
type="default"
@click="handleReloadTheme"
>
重载主题配置
{{ $t("core.theme.operations.reload.button") }}
</VButton>
<VButton
v-close-popper
@ -124,7 +133,7 @@ const onUpgradeModalClose = () => {
type="danger"
@click="handleResetSettingConfig"
>
重置
{{ $t("core.common.buttons.reset") }}
</VButton>
</VSpace>
</div>
@ -145,7 +154,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">作者</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.author") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ selectedTheme?.spec.author.name }}
</dd>
@ -153,7 +164,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">网站</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.website") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a
:href="selectedTheme?.spec.website"
@ -167,7 +180,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">源码仓库</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.repo") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a
:href="selectedTheme?.spec.repo"
@ -181,7 +196,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">当前版本</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.version") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ selectedTheme?.spec.version }}
</dd>
@ -189,7 +206,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">Halo 版本要求</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.requires") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ selectedTheme?.spec.requires }}
</dd>
@ -197,7 +216,9 @@ const onUpgradeModalClose = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">存储位置</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.storage_location") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ selectedTheme?.status?.location }}
</dd>
@ -207,7 +228,9 @@ const onUpgradeModalClose = () => {
v-if="false"
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">插件依赖</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.theme.detail.fields.plugin_requires") }}
</dt>
<dd class="mt-1 text-sm sm:col-span-3 sm:mt-0">
<VAlert
description="当前有 1 个插件还未安装"
@ -229,7 +252,7 @@ const onUpgradeModalClose = () => {
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag> 已安装</VTag>
<VTag>{{ $t("core.common.status.installed") }}</VTag>
</VSpace>
</div>
</div>
@ -243,7 +266,9 @@ const onUpgradeModalClose = () => {
</span>
<div class="text-xs">
<VSpace>
<VTag>未安装</VTag>
<VTag>
{{ $t("core.common.status.not_installed") }}
</VTag>
</VSpace>
</div>
</div>

View File

@ -13,6 +13,9 @@ import type { ConfigMap, Setting, Theme } from "@halo-dev/api-client";
import { useRouteParams } from "@vueuse/router";
import { apiClient } from "@/utils/api-client";
import { useSettingFormConvert } from "@/composables/use-setting-form";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const group = useRouteParams<string>("group");
@ -63,7 +66,7 @@ const handleSaveConfigMap = async () => {
configMap: configMapToUpdate,
});
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
await handleFetchSettings();
configMap.value = newConfigMap;
@ -109,7 +112,7 @@ watch(
type="secondary"
@click="$formkit.submit(group || '')"
>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>

View File

@ -21,6 +21,9 @@ import { computed, ref, watch } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -48,7 +51,9 @@ const creating = ref(false);
const refreshInterval = ref();
const modalTitle = computed(() => {
return activeTab.value === "installed" ? "已安装的主题" : "未安装的主题";
return activeTab.value === "installed"
? t("core.theme.list_modal.titles.installed_themes")
: t("core.theme.list_modal.titles.not_installed_themes");
});
const handleFetchThemes = async (options?: { mute?: boolean }) => {
@ -110,7 +115,7 @@ const handleCreateTheme = async (theme: Theme) => {
activeTab.value = "installed";
Toast.success("安装成功");
Toast.success(t("core.theme.list_modal.operations.install.toast_success"));
} catch (error) {
console.error("Failed to create theme", error);
} finally {
@ -183,17 +188,21 @@ const handleOpenInstallModal = () => {
type="outline"
class="my-[12px] mx-[16px]"
>
<VTabItem id="installed" label="已安装" class="-mx-[16px]">
<VTabItem
id="installed"
:label="$t('core.theme.list_modal.tabs.installed')"
class="-mx-[16px]"
>
<VLoading v-if="loading" />
<Transition v-else-if="!themes.length" appear name="fade">
<VEmpty
message="当前没有已安装的主题,你可以尝试刷新或者安装新主题"
title="当前没有已安装的主题"
:message="$t('core.theme.list_modal.empty.message')"
:title="$t('core.theme.list_modal.empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="loading" @click="handleFetchThemes()">
刷新
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:themes:manage']"
@ -203,7 +212,7 @@ const handleOpenInstallModal = () => {
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
安装主题
{{ $t("core.theme.common.buttons.install") }}
</VButton>
</VSpace>
</template>
@ -232,14 +241,20 @@ const handleOpenInstallModal = () => {
</ul>
</Transition>
</VTabItem>
<VTabItem id="uninstalled" label="未安装" class="-mx-[16px]">
<VTabItem
id="uninstalled"
:label="$t('core.theme.list_modal.tabs.not_installed')"
class="-mx-[16px]"
>
<VLoading v-if="loading" />
<Transition v-else-if="!themes.length" appear name="fade">
<VEmpty title="当前没有未安装的主题">
<VEmpty
:title="$t('core.theme.list_modal.not_installed_empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="loading" @click="handleFetchThemes">
刷新
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
@ -269,18 +284,18 @@ const handleOpenInstallModal = () => {
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400"
>加载中...</span
>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400"
>加载异常</span
>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
@ -325,7 +340,7 @@ const handleOpenInstallModal = () => {
:disabled="creating"
@click="handleCreateTheme(theme)"
>
安装
{{ $t("core.common.buttons.install") }}
</VButton>
</template>
</VEntityField>
@ -344,9 +359,11 @@ const handleOpenInstallModal = () => {
type="secondary"
@click="handleOpenInstallModal()"
>
安装主题
{{ $t("core.theme.common.buttons.install") }}
</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
<VButton @click="onVisibleChange(false)"></VButton>
</VSpace>
</template>
</VModal>

View File

@ -3,6 +3,9 @@ import { VModal } from "@halo-dev/components";
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { computed, ref, watch } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -24,8 +27,10 @@ const uploadVisible = ref(false);
const modalTitle = computed(() => {
return props.upgradeTheme
? `升级主题(${props.upgradeTheme.spec.displayName}`
: "安装主题";
? t("core.theme.upload_modal.titles.upgrade", {
display_name: props.upgradeTheme.spec.displayName,
})
: t("core.theme.upload_modal.titles.install");
});
const handleVisibleChange = (visible: boolean) => {

View File

@ -16,8 +16,10 @@ import { apiClient } from "@/utils/api-client";
import { toRefs } from "vue";
import { useThemeLifeCycle } from "../../composables/use-theme";
import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -48,10 +50,12 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
Dialog.warning({
title: `${
deleteExtensions
? "确定要卸载该主题以及对应的配置吗?"
: "确定要卸载该主题吗?"
? t("core.theme.operations.uninstall_and_delete_config.title")
: t("core.theme.operations.uninstall.title")
}`,
description: "该操作不可恢复。",
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.theme.deletethemeHaloRunV1alpha1Theme({
@ -85,7 +89,7 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
}
}
Toast.success("卸载成功");
Toast.success(t("core.common.toast.uninstall_success"));
} catch (e) {
console.error("Failed to uninstall theme", e);
} finally {
@ -115,14 +119,18 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">加载中...</span>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">加载异常</span>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
@ -135,7 +143,9 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
:description="theme.spec.version"
>
<template #extra>
<VTag v-if="isActivated"> </VTag>
<VTag v-if="isActivated">
{{ $t("core.common.status.activated") }}
</VTag>
</template>
</VEntityField>
</template>
@ -147,7 +157,11 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
</VEntityField>
<VEntityField v-if="theme.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
@ -185,16 +199,18 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
type="secondary"
@click="handleActiveTheme"
>
启用
{{ $t("core.common.buttons.active") }}
</VButton>
<VButton v-close-popper block type="default" @click="emit('upgrade')">
升级
{{ $t("core.common.buttons.upgrade") }}
</VButton>
<VButton v-close-popper block type="default" @click="emit('preview')">
预览
{{ $t("core.common.buttons.preview") }}
</VButton>
<FloatingDropdown class="w-full" placement="right" :triggers="['click']">
<VButton block type="danger"> 卸载 </VButton>
<VButton block type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VButton>
<template #popper>
<div class="w-52 p-2">
<VSpace class="w-full" direction="column">
@ -204,7 +220,7 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
type="danger"
@click="handleUninstall(theme)"
>
卸载
{{ $t("core.common.buttons.uninstall") }}
</VButton>
<VButton
v-close-popper.all
@ -212,7 +228,9 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
type="danger"
@click="handleUninstall(theme, true)"
>
卸载并删除配置
{{
$t("core.theme.operations.uninstall_and_delete_config.button")
}}
</VButton>
</VSpace>
</div>
@ -224,7 +242,7 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
type="danger"
@click="handleResetSettingConfig"
>
重置
{{ $t("core.common.buttons.reset") }}
</VButton>
</template>
</VEntity>

View File

@ -43,14 +43,18 @@ const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">加载中...</span>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">加载异常</span>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
@ -63,7 +67,9 @@ const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
:description="theme.spec.version"
>
<template #extra>
<VTag v-if="isActivated"> </VTag>
<VTag v-if="isActivated">
{{ $t("core.common.status.activated") }}
</VTag>
</template>
</VEntityField>
</template>
@ -76,7 +82,7 @@ const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
type="secondary"
@click="handleActiveTheme"
>
启用
{{ $t("core.common.buttons.active") }}
</VButton>
<VButton
v-close-popper
@ -84,7 +90,7 @@ const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
type="default"
@click="emit('open-settings')"
>
设置
{{ $t("core.common.buttons.setting") }}
</VButton>
</template>
</VEntity>

View File

@ -25,6 +25,7 @@ import {
} from "@halo-dev/components";
import { storeToRefs } from "pinia";
import { computed, markRaw, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -44,6 +45,8 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
interface SettingTab {
id: string;
label: string;
@ -112,7 +115,9 @@ const modalTitle = computed(() => {
if (props.title) {
return props.title;
}
return `预览主题:${selectedTheme.value?.spec.displayName}`;
return t("core.theme.preview_model.title", {
display_name: selectedTheme.value?.spec.displayName,
});
});
// theme settings
@ -164,7 +169,7 @@ const handleSaveConfigMap = async () => {
configMap: configMapToUpdate,
});
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
await handleFetchSettings();
configMap.value = newConfigMap;
@ -254,23 +259,40 @@ const iframeClasses = computed(() => {
</template>
<template #actions>
<span
v-tooltip="{ content: '切换主题', delay: 300 }"
v-tooltip="{
content: $t('core.theme.empty.actions.switch'),
delay: 300,
}"
:class="{ 'bg-gray-200': themesVisible }"
@click="handleOpenThemes"
>
<IconPalette />
</span>
<span
v-tooltip="{ content: '主题设置', delay: 300 }"
v-tooltip="{
content: $t('core.theme.preview_model.actions.setting'),
delay: 300,
}"
:class="{ 'bg-gray-200': settingsVisible }"
@click="handleOpenSettings(undefined)"
>
<IconSettings />
</span>
<span v-tooltip="{ content: '刷新', delay: 300 }" @click="handleRefresh">
<span
v-tooltip="{
content: $t('core.common.buttons.refresh'),
delay: 300,
}"
@click="handleRefresh"
>
<IconRefreshLine />
</span>
<span v-tooltip="{ content: '新窗口打开', delay: 300 }">
<span
v-tooltip="{
content: $t('core.theme.preview_model.actions.open'),
delay: 300,
}"
>
<a :href="previewUrl" target="_blank">
<IconLink />
</a>
@ -347,7 +369,7 @@ const iframeClasses = computed(() => {
)
"
>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
interface useThemeLifeCycleReturn {
loading: Ref<boolean>;
@ -17,6 +18,8 @@ interface useThemeLifeCycleReturn {
export function useThemeLifeCycle(
theme: Ref<Theme | undefined>
): useThemeLifeCycleReturn {
const { t } = useI18n();
const loading = ref(false);
const themeStore = useThemeStore();
@ -41,8 +44,10 @@ export function useThemeLifeCycle(
const handleActiveTheme = async () => {
Dialog.info({
title: "是否确认启用当前主题",
title: t("core.theme.operations.active.title"),
description: theme.value?.spec.displayName,
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (!theme.value) return;
@ -51,7 +56,7 @@ export function useThemeLifeCycle(
name: theme.value?.metadata.name,
});
Toast.success("启用成功");
Toast.success(t("core.theme.operations.active.toast_success"));
} catch (e) {
console.error("Failed to active theme", e);
} finally {
@ -63,9 +68,11 @@ export function useThemeLifeCycle(
const handleResetSettingConfig = async () => {
Dialog.warning({
title: "确定要重置主题的所有配置吗?",
description: "该操作会删除已保存的配置,重置为默认配置。",
title: t("core.theme.operations.reset.title"),
description: t("core.theme.operations.reset.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (!theme?.value) {
@ -76,7 +83,7 @@ export function useThemeLifeCycle(
name: theme.value.metadata.name as string,
});
Toast.success("重置配置成功");
Toast.success(t("core.theme.operations.reset.toast_success"));
} catch (e) {
console.error("Failed to reset theme setting config", e);
}
@ -95,10 +102,12 @@ export function useThemeLifeCycle(
export function useThemeCustomTemplates(type: "post" | "page" | "category") {
const themeStore = useThemeStore();
const { t } = useI18n();
const templates = computed(() => {
const defaultTemplate = [
{
label: "默认模板",
label: t("core.theme.custom_templates.default"),
value: "",
},
];

View File

@ -32,8 +32,10 @@ import { usePermission } from "@/utils/permission";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
interface ThemeTab {
id: string;
@ -47,7 +49,7 @@ interface ThemeTab {
const initialTabs: ThemeTab[] = [
{
id: "detail",
label: "详情",
label: t("core.theme.tabs.detail"),
route: {
name: "ThemeDetail",
},
@ -179,19 +181,19 @@ onMounted(() => {
type="primary"
@click="handleActiveTheme"
>
启用
{{ $t("core.common.buttons.active") }}
</VButton>
<VButton type="secondary" size="sm" @click="previewModal = true">
<template #icon>
<IconEye class="h-full w-full" />
</template>
预览
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton size="sm" type="default" @click="themesModal = true">
<template #icon>
<IconExchange class="h-full w-full" />
</template>
主题管理
{{ $t("core.theme.actions.management") }}
</VButton>
</VSpace>
</template>
@ -200,17 +202,19 @@ onMounted(() => {
<div class="m-0 md:m-4">
<VEmpty
v-if="!selectedTheme && !loading"
message="当前没有已激活或者选择的主题,你可以切换主题或者安装新主题"
title="当前没有已激活或已选择的主题"
:message="$t('core.theme.empty.message')"
:title="$t('core.theme.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="themesModal = true"> 安装主题 </VButton>
<VButton @click="themesModal = true">
{{ $t("core.theme.common.buttons.install") }}
</VButton>
<VButton type="primary" @click="themesModal = true">
<template #icon>
<IconExchange class="h-full w-full" />
</template>
切换主题
{{ $t("core.theme.empty.actions.switch") }}
</VButton>
</VSpace>
</template>

View File

@ -17,11 +17,11 @@ export default definePlugin({
name: "ThemeDetail",
component: ThemeDetail,
meta: {
title: "主题",
title: "core.theme.title",
searchable: true,
permissions: ["system:themes:view"],
menu: {
name: "主题",
name: "core.sidebar.menu.items.themes",
group: "interface",
icon: markRaw(IconPalette),
priority: 0,
@ -33,7 +33,7 @@ export default definePlugin({
name: "ThemeSetting",
component: ThemeSetting,
meta: {
title: "主题设置",
title: "core.theme.settings.title",
permissions: ["system:themes:view"],
},
},

View File

@ -13,6 +13,9 @@ import type { Info, GlobalInfo, Startup } from "./types";
import axios from "axios";
import { formatDatetime } from "@/utils/date";
import { useClipboard } from "@vueuse/core";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const info = ref<Info>();
const globalInfo = ref<GlobalInfo>();
@ -68,23 +71,36 @@ const { copy, isSupported } = useClipboard();
const handleCopy = () => {
if (!isSupported.value) {
Toast.warning("当前浏览器不支持复制");
Toast.warning(t("core.actuator.actions.copy.toast_browser_not_supported"));
return;
}
const text = `
- 外部访问地址${globalInfo.value?.externalUrl}
- 启动时间${formatDatetime(startup.value?.timeline.startTime)}
- Halo 版本${info.value?.build?.version}
- 构建时间${formatDatetime(info.value?.build?.time)}
- ${t("core.actuator.copy_results.external_url", {
external_url: globalInfo.value?.externalUrl,
})}
- ${t("core.actuator.copy_results.start_time", {
start_time: formatDatetime(startup.value?.timeline.startTime),
})}
- ${t("core.actuator.fields.version", { version: info.value?.build?.version })}
- ${t("core.actuator.copy_results.build_time", {
build_time: formatDatetime(info.value?.build?.time),
})}
- Git Commit${info.value?.git?.commit.id}
- Java${info.value?.java.runtime.name} / ${info.value?.java.runtime.version}
- 数据库${info.value?.database.name} / ${info.value?.database.version}
- 操作系统${info.value?.os.name} / ${info.value?.os.version}
- ${t("core.actuator.copy_results.database", {
database: [info.value?.database.name, info.value?.database.version].join(
" / "
),
})}
- ${t("core.actuator.copy_results.os", {
os: [info.value?.os.name, info.value?.os.version].join(" / "),
})}
`;
copy(text);
Toast.success("复制成功");
Toast.success(t("core.common.toast.copy_success"));
};
const handleDownloadLogfile = () => {
@ -101,17 +117,17 @@ const handleDownloadLogfile = () => {
document.body.removeChild(downloadElement);
window.URL.revokeObjectURL(href);
Toast.success("下载成功");
Toast.success(t("core.common.toast.download_success"));
})
.catch((e) => {
Toast.error("下载失败");
Toast.error(t("core.common.toast.download_failed"));
console.log("Failed to download log file.", e);
});
};
</script>
<template>
<VPageHeader title="系统概览">
<VPageHeader :title="$t('core.actuator.title')">
<template #icon>
<IconTerminalBoxLine class="mr-2 self-center" />
</template>
@ -120,7 +136,7 @@ const handleDownloadLogfile = () => {
<template #icon>
<IconClipboardLine class="h-full w-full" />
</template>
复制
{{ $t("core.common.buttons.copy") }}
</VButton>
</template>
</VPageHeader>
@ -133,7 +149,7 @@ const handleDownloadLogfile = () => {
>
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900">
基本信息
{{ $t("core.actuator.header.titles.general") }}
</h3>
</div>
</div>
@ -142,7 +158,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">外部访问地址</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.external_url") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<span>
{{ globalInfo?.externalUrl }}
@ -151,11 +169,11 @@ const handleDownloadLogfile = () => {
v-if="!isExternalUrlValid"
class="mt-3"
type="warning"
title="警告"
:title="$t('core.common.text.warning')"
:closable="false"
>
<template #description>
检测到外部访问地址与当前访问地址不一致可能会导致部分链接无法正常跳转请检查外部访问地址设置
{{ $t("core.actuator.alert.external_url_invalid") }}
</template>
</VAlert>
</dd>
@ -163,7 +181,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">启动时间</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.start_time") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ formatDatetime(startup?.timeline.startTime) }}
</dd>
@ -171,7 +191,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">时区</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.timezone") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ globalInfo?.timeZone }}
</dd>
@ -179,7 +201,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">语言</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.locale") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ globalInfo?.locale }}
</dd>
@ -195,7 +219,7 @@ const handleDownloadLogfile = () => {
>
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900">
环境信息
{{ $t("core.actuator.header.titles.environment") }}
</h3>
</div>
</div>
@ -205,7 +229,9 @@ const handleDownloadLogfile = () => {
v-if="info.build"
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">版本</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.version") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<a
:href="`https://github.com/halo-dev/halo/releases/tag/v${info.build.version}`"
@ -220,7 +246,9 @@ const handleDownloadLogfile = () => {
v-if="info.build"
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">构建时间</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.build_time") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ formatDatetime(info.build.time) }}
</dd>
@ -251,7 +279,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">数据库</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.database") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ [info.database.name, info.database.version].join(" / ") }}
</dd>
@ -259,7 +289,9 @@ const handleDownloadLogfile = () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">操作系统</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.os") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ info.os.name }} {{ info.os.version }} / {{ info.os.arch }}
</dd>
@ -267,10 +299,12 @@ const handleDownloadLogfile = () => {
<div
class="items-center bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">运行日志</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.actuator.fields.log") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<VButton size="sm" @click="handleDownloadLogfile()">
下载
{{ $t("core.common.buttons.download") }}
</VButton>
</dd>
</div>

View File

@ -15,10 +15,10 @@ export default definePlugin({
path: "",
component: Actuator,
meta: {
title: "系统概览",
title: "core.actuator.title",
searchable: true,
menu: {
name: "概览",
name: "core.sidebar.menu.items.actuator",
group: "system",
icon: markRaw(IconTerminalBoxLine),
priority: 3,

View File

@ -13,13 +13,15 @@ import {
VTabbar,
} from "@halo-dev/components";
import { useSettingFormConvert } from "@/composables/use-setting-form";
import { useI18n } from "vue-i18n";
const route = useRoute();
const { t } = useI18n();
const tabs = ref<{ id: string; label: string }[]>([
{
id: "detail",
label: "详情",
label: t("core.identity_authentication.tabs.detail"),
},
]);
@ -40,7 +42,7 @@ const { data: authProvider } = useQuery<AuthProvider>({
if (data.spec.settingRef?.name) {
tabs.value.push({
id: "setting",
label: "设置",
label: t("core.identity_authentication.tabs.setting"),
});
}
},
@ -132,7 +134,7 @@ const handleSaveConfigMap = async () => {
configMap: configMapToUpdate,
});
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
await handleFetchSettings();
await handleFetchConfigMap();
@ -169,7 +171,11 @@ const handleSaveConfigMap = async () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">名称</dt>
<dt class="text-sm font-medium text-gray-900">
{{
$t("core.identity_authentication.detail.fields.display_name")
}}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ authProvider?.spec.displayName }}
</dd>
@ -177,7 +183,11 @@ const handleSaveConfigMap = async () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">描述</dt>
<dt class="text-sm font-medium text-gray-900">
{{
$t("core.identity_authentication.detail.fields.description")
}}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ authProvider?.spec.description }}
</dd>
@ -185,7 +195,9 @@ const handleSaveConfigMap = async () => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">网站</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.identity_authentication.detail.fields.website") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a
v-if="authProvider?.spec.website"
@ -194,13 +206,17 @@ const handleSaveConfigMap = async () => {
>
{{ authProvider.spec.website }}
</a>
<span v-else></span>
<span v-else>
{{ $t("core.common.text.none") }}
</span>
</dd>
</div>
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">帮助页面</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.identity_authentication.detail.fields.help_page") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a
v-if="authProvider?.spec.helpPage"
@ -209,13 +225,19 @@ const handleSaveConfigMap = async () => {
>
{{ authProvider.spec.helpPage }}
</a>
<span v-else></span>
<span v-else>{{ $t("core.common.text.none") }}</span>
</dd>
</div>
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">登录入口</dt>
<dt class="text-sm font-medium text-gray-900">
{{
$t(
"core.identity_authentication.detail.fields.authentication_url"
)
}}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ authProvider?.spec.authenticationUrl }}
</dd>
@ -247,7 +269,7 @@ const handleSaveConfigMap = async () => {
type="secondary"
@click="$formkit.submit(group || '')"
>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>

View File

@ -20,7 +20,7 @@ const {
</script>
<template>
<VPageHeader title="身份认证">
<VPageHeader :title="$t('core.identity_authentication.title')">
<template #icon>
<IconLockPasswordLine class="mr-2 self-center" />
</template>
@ -34,7 +34,10 @@ const {
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<FormKit placeholder="输入关键词搜索" type="text"></FormKit>
<FormKit
:placeholder="$t('core.common.placeholder.search')"
type="text"
></FormKit>
</div>
</div>
</div>

View File

@ -9,6 +9,9 @@ import {
VEntityField,
VSwitch,
} from "@halo-dev/components";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
authProvider: ListedAuthProvider;
@ -20,9 +23,11 @@ const emit = defineEmits<{
const handleChangeStatus = async () => {
Dialog.info({
title: `确定要${
props.authProvider.enabled ? "停用" : "启用"
}该身份认证方式吗`,
title: props.authProvider.enabled
? t("core.identity_authentication.operations.disable.title")
: t("core.identity_authentication.operations.enable.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (props.authProvider.enabled) {
@ -30,12 +35,12 @@ const handleChangeStatus = async () => {
name: props.authProvider.name,
});
Toast.success("停用成功");
Toast.success(t("core.common.toast.inactive_success"));
} else {
await apiClient.authProvider.enableAuthProvider({
name: props.authProvider.name,
});
Toast.success("启用成功");
Toast.success(t("core.common.toast.active_success"));
}
emit("reload");

View File

@ -56,13 +56,19 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div>
<div class="flex items-center justify-between bg-white px-4 py-4 sm:px-6">
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900">插件信息</h3>
<h3 class="text-lg font-medium leading-6 text-gray-900">
{{ $t("core.plugin.detail.header.title") }}
</h3>
<p class="mt-1 flex max-w-2xl items-center gap-2">
<span class="text-sm text-gray-500">{{
plugin?.spec.version
}}</span>
<span class="text-sm text-gray-500">
{{ plugin?.spec.version }}
</span>
<VTag>
{{ isStarted ? "已启用" : "未启用" }}
{{
isStarted
? $t("core.common.status.activated")
: $t("core.common.status.not_activated")
}}
</VTag>
</p>
</div>
@ -75,7 +81,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">名称</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.display_name") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ plugin?.spec.displayName }}
</dd>
@ -83,7 +91,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">描述</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.description") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ plugin?.spec.description }}
</dd>
@ -91,7 +101,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">版本</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.version") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ plugin?.spec.version }}
</dd>
@ -99,7 +111,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">Halo 版本要求</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.requires") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ plugin?.spec.requires }}
</dd>
@ -107,7 +121,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">提供方</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.author") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<a
v-if="plugin?.spec.author"
@ -116,13 +132,17 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
>
{{ plugin?.spec.author.name }}
</a>
<span v-else></span>
<span v-else>
{{ $t("core.common.text.none") }}
</span>
</dd>
</div>
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">协议</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.license") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<ul
v-if="plugin?.spec.license && plugin?.spec.license.length"
@ -150,7 +170,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
>
<dt class="text-sm font-medium text-gray-900">模型定义</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<span></span>
<span>
{{ $t("core.common.text.none") }}
</span>
</dd>
</div>
<div
@ -159,7 +181,9 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
}`"
class="px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">权限模板</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.role_templates") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-5 sm:mt-0">
<dl
v-if="pluginRoleTemplateGroups.length"
@ -195,13 +219,14 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
"
class="text-xs text-gray-400"
>
依赖于
{{
JSON.parse(
role.metadata.annotations?.[
rbacAnnotations.DEPENDENCIES
]
).join(", ")
$t("core.role.common.text.dependent_on", {
roles: JSON.parse(
role.metadata.annotations?.[
rbacAnnotations.DEPENDENCIES
]
).join(", "),
})
}}
</span>
</div>
@ -211,13 +236,17 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
</dd>
</div>
</dl>
<span v-else></span>
<span v-else>
{{ $t("core.common.text.none") }}
</span>
</dd>
</div>
<div
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-6 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">最近一次启动</dt>
<dt class="text-sm font-medium text-gray-900">
{{ $t("core.plugin.detail.fields.last_starttime") }}
</dt>
<dd
class="mt-1 text-sm tabular-nums text-gray-900 sm:col-span-2 sm:mt-0"
>

View File

@ -18,10 +18,13 @@ import { computed, ref } from "vue";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
import FilterTag from "@/components/filter/FilterTag.vue";
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core";
import { useQuery } from "@tanstack/vue-query";
import type { Plugin } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface EnabledItem {
label: string;
@ -45,26 +48,26 @@ const total = ref(0);
// Filters
const EnabledItems: EnabledItem[] = [
{
label: "全部",
label: t("core.plugin.filters.status.items.all"),
value: undefined,
},
{
label: "已启用",
label: t("core.plugin.filters.status.items.active"),
value: true,
},
{
label: "未启用",
label: t("core.plugin.filters.status.items.inactive"),
value: false,
},
];
const SortItems: SortItem[] = [
{
label: "较近安装",
label: t("core.plugin.filters.sort.items.create_time_desc"),
value: "creationTimestamp,desc",
},
{
label: "较早安装",
label: t("core.plugin.filters.sort.items.create_time_asc"),
value: "creationTimestamp,asc",
},
];
@ -150,7 +153,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
@close="refetch()"
/>
<VPageHeader title="插件">
<VPageHeader :title="$t('core.plugin.title')">
<template #icon>
<IconPlug class="mr-2 self-center" />
</template>
@ -163,7 +166,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
安装
{{ $t("core.common.buttons.install") }}
</VButton>
</template>
</VPageHeader>
@ -179,7 +182,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<FormKit
id="keywordInput"
outer-class="!p-0"
placeholder="输入关键词搜索"
:placeholder="$t('core.common.placeholder.search')"
type="text"
name="keyword"
:model-value="keyword"
@ -187,24 +190,39 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
{{
$t("core.common.filters.results.keyword", {
keyword: keyword,
})
}}
</FilterTag>
<FilterTag
v-if="selectedEnabledItem?.value !== undefined"
@close="handleEnabledItemChange(EnabledItems[0])"
>
启用状态{{ selectedEnabledItem.label }}
{{
$t("core.common.filters.results.status", {
status: selectedEnabledItem.label,
})
}}
</FilterTag>
<FilterTag
v-if="selectedSortItem"
@close="handleSortItemChange()"
>
排序{{ selectedSortItem.label }}
{{
$t("core.common.filters.results.sort", {
sort: selectedSortItem.label,
})
}}
</FilterTag>
<FilteCleanButton v-if="hasFilters" @click="handleClearFilters" />
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
@ -212,7 +230,9 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">启用状态</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.status") }}
</span>
<span>
<IconArrowDown />
</span>
@ -241,7 +261,9 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span class="mr-0.5">
{{ $t("core.common.filters.labels.sort") }}
</span>
<span>
<IconArrowDown />
</span>
@ -268,7 +290,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
@click="refetch()"
>
<IconRefreshLine
v-tooltip="`刷新`"
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
@ -284,12 +306,14 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<Transition v-else-if="!data?.length" appear name="fade">
<VEmpty
message="当前没有已安装的插件,你可以尝试刷新或者安装新插件"
title="当前没有已安装的插件"
:message="$t('core.plugin.empty.message')"
:title="$t('core.plugin.empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch()"></VButton>
<VButton :loading="isFetching" @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:plugins:manage']"
type="secondary"
@ -298,7 +322,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
安装插件
{{ $t("core.plugin.empty.actions.install") }}
</VButton>
</VSpace>
</template>
@ -321,6 +345,8 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total="total"
:size-options="[10, 20, 30, 50, 100]"
/>

View File

@ -12,6 +12,9 @@ import { Toast, VButton } from "@halo-dev/components";
// types
import type { ConfigMap, Plugin, Setting } from "@halo-dev/api-client";
import { useRouteParams } from "@vueuse/router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const group = useRouteParams<string>("group");
@ -55,7 +58,7 @@ const handleSaveConfigMap = async () => {
configMap: configMapToUpdate,
});
Toast.success("保存成功");
Toast.success(t("core.common.toast.save_success"));
await handleFetchSettings();
configMap.value = newConfigMap;
@ -92,7 +95,7 @@ await handleFetchConfigMap();
type="secondary"
@click="$formkit.submit(group || '')"
>
保存
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>

View File

@ -18,8 +18,10 @@ import type { Plugin } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -46,9 +48,11 @@ const onUpgradeModalClose = () => {
const handleResetSettingConfig = async () => {
Dialog.warning({
title: "确定要重置插件的所有配置吗?",
description: "该操作会删除已保存的配置,重置为默认配置。",
title: t("core.plugin.operations.reset.title"),
description: t("core.plugin.operations.reset.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (!plugin?.value) {
@ -59,7 +63,7 @@ const handleResetSettingConfig = async () => {
name: plugin.value.metadata.name as string,
});
Toast.success("重置配置成功");
Toast.success(t("core.plugin.operations.reset.toast_success"));
} catch (e) {
console.error("Failed to reset plugin setting config", e);
}
@ -102,7 +106,11 @@ const getFailedMessage = (plugin: Plugin) => {
<template #extra>
<VSpace>
<VTag>
{{ isStarted ? "已启用" : "未启用" }}
{{
isStarted
? $t("core.common.status.activated")
: $t("core.common.status.not_activated")
}}
</VTag>
</VSpace>
</template>
@ -120,7 +128,11 @@ const getFailedMessage = (plugin: Plugin) => {
</VEntityField>
<VEntityField v-if="plugin?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="plugin?.spec.author">
@ -163,10 +175,12 @@ const getFailedMessage = (plugin: Plugin) => {
type="secondary"
@click="upgradeModal = true"
>
升级
{{ $t("core.common.buttons.upgrade") }}
</VButton>
<FloatingDropdown class="w-full" placement="left" :triggers="['click']">
<VButton block type="danger"> 卸载 </VButton>
<VButton block type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VButton>
<template #popper>
<div class="w-52 p-2">
<VSpace class="w-full" direction="column">
@ -176,7 +190,7 @@ const getFailedMessage = (plugin: Plugin) => {
type="danger"
@click="uninstall"
>
卸载
{{ $t("core.common.buttons.uninstall") }}
</VButton>
<VButton
v-close-popper.all
@ -184,7 +198,7 @@ const getFailedMessage = (plugin: Plugin) => {
type="danger"
@click="uninstall(true)"
>
卸载并删除配置
{{ $t("core.plugin.list.actions.uninstall_and_delete_config") }}
</VButton>
</VSpace>
</div>
@ -196,7 +210,7 @@ const getFailedMessage = (plugin: Plugin) => {
type="danger"
@click="handleResetSettingConfig"
>
重置
{{ $t("core.common.buttons.reset") }}
</VButton>
</template>
</VEntity>

View File

@ -6,6 +6,9 @@ import type { Plugin } from "@halo-dev/api-client";
import { computed, ref, watch } from "vue";
import type { SuccessResponse, ErrorResponse } from "@uppy/core";
import type { UppyFile } from "@uppy/utils";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -27,8 +30,10 @@ const uploadVisible = ref(false);
const modalTitle = computed(() => {
return props.upgradePlugin
? `升级插件(${props.upgradePlugin.spec.displayName}`
: "安装插件";
? t("core.plugin.upload_modal.titles.upgrade", {
display_name: props.upgradePlugin.spec.displayName,
})
: t("core.plugin.upload_modal.titles.install");
});
const handleVisibleChange = (visible: boolean) => {
@ -54,8 +59,12 @@ const onUploaded = async (response: SuccessResponse) => {
const plugin = response.body as Plugin;
handleVisibleChange(false);
Dialog.success({
title: "上传成功",
description: "是否启动当前安装的插件?",
title: t("core.plugin.upload_modal.operations.active_after_install.title"),
description: t(
"core.plugin.upload_modal.operations.active_after_install.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const { data: pluginToUpdate } =
@ -95,15 +104,21 @@ const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as PluginInstallationErrorResponse;
if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) {
Dialog.info({
title: "插件已存在",
description: "当前安装的插件已存在,是否升级?",
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.plugin.upgradePlugin({
name: body.pluginName,
file: file.data as File,
});
Toast.success("升级成功");
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},

View File

@ -4,6 +4,7 @@ import type { Plugin } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import { useI18n } from "vue-i18n";
interface usePluginLifeCycleReturn {
isStarted: ComputedRef<boolean | undefined>;
@ -14,6 +15,8 @@ interface usePluginLifeCycleReturn {
export function usePluginLifeCycle(
plugin?: Ref<Plugin | undefined>
): usePluginLifeCycleReturn {
const { t } = useI18n();
const isStarted = computed(() => {
return (
plugin?.value?.status?.phase === "STARTED" && plugin.value?.spec.enabled
@ -26,7 +29,11 @@ export function usePluginLifeCycle(
const pluginToUpdate = cloneDeep(plugin.value);
Dialog.info({
title: `确定要${pluginToUpdate.spec.enabled ? "停止" : "启动"}该插件吗?`,
title: pluginToUpdate.spec.enabled
? t("core.plugin.operations.change_status.inactive_title")
: t("core.plugin.operations.change_status.active_title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
pluginToUpdate.spec.enabled = !pluginToUpdate.spec.enabled;
@ -35,7 +42,11 @@ export function usePluginLifeCycle(
plugin: pluginToUpdate,
});
Toast.success(`${pluginToUpdate.spec.enabled ? "启动" : "停止"}成功`);
Toast.success(
pluginToUpdate.spec.enabled
? t("core.common.toast.active_success")
: t("core.common.toast.inactive_success")
);
} catch (e) {
console.error(e);
} finally {
@ -53,16 +64,21 @@ export function usePluginLifeCycle(
Dialog.warning({
title: `${
deleteExtensions
? "确定要卸载该插件以及对应的配置吗?"
: "确定要卸载该插件吗?"
? t("core.plugin.operations.uninstall_and_delete_config.title")
: t("core.plugin.operations.uninstall.title")
}`,
description: `${
enabled
? "当前插件还在启用状态,将在停止运行后卸载,该操作不可恢复。"
: "该操作不可恢复。"
? t("core.plugin.operations.uninstall_when_enabled.description")
: t("core.common.dialog.descriptions.cannot_be_recovered")
}`,
confirmType: "danger",
confirmText: `${enabled ? "停止运行并卸载" : "卸载"}`,
confirmText: `${
enabled
? t("core.plugin.operations.uninstall_when_enabled.confirm_text")
: t("core.common.buttons.uninstall")
}`,
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (!plugin.value) return;
@ -107,7 +123,7 @@ export function usePluginLifeCycle(
}
}
Toast.success("卸载成功");
Toast.success(t("core.common.toast.uninstall_success"));
} catch (e) {
console.error("Failed to uninstall plugin", e);
} finally {

View File

@ -21,8 +21,10 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
import type { Ref } from "vue";
import type { Plugin, Setting, SettingForm } from "@halo-dev/api-client";
import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
interface PluginTab {
id: string;
@ -36,7 +38,7 @@ interface PluginTab {
const initialTabs: PluginTab[] = [
{
id: "detail",
label: "详情",
label: t("core.plugin.tabs.detail"),
route: {
name: "PluginDetail",
},

View File

@ -24,11 +24,11 @@ export default definePlugin({
name: "Plugins",
component: PluginList,
meta: {
title: "插件",
title: "core.plugin.title",
searchable: true,
permissions: ["system:plugins:view"],
menu: {
name: "插件",
name: "core.sidebar.menu.items.plugins",
group: "system",
icon: markRaw(IconPlug),
priority: 0,
@ -46,7 +46,7 @@ export default definePlugin({
name: "PluginDetail",
component: PluginDetail,
meta: {
title: "插件详情",
title: "core.plugin.detail.title",
permissions: ["system:plugins:view"],
},
},
@ -55,7 +55,7 @@ export default definePlugin({
name: "PluginSetting",
component: PluginSetting,
meta: {
title: "插件设置",
title: "core.plugin.settings.title",
permissions: ["system:plugins:manage"],
},
},

Some files were not shown because too many files have changed in this diff Show More