Compare commits
425
Commits
4f8ab94fac
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b60eb427 | ||
|
|
ef15416ef7 | ||
|
|
0cfd4c898b | ||
|
|
9a22c7eb8b | ||
|
|
de0e5cd481 | ||
|
|
a120f61e13 | ||
|
|
0d51beeaad | ||
|
|
573040381d | ||
|
|
7a0445fc4a | ||
|
|
0bc72616e5 | ||
|
|
064f4e0955 | ||
|
|
4b5098a753 | ||
|
|
05f16d5fd7 | ||
|
|
af8dccdc45 | ||
|
|
dcb80cff6e | ||
|
|
2e2558e5ce | ||
|
|
817ac9c647 | ||
|
|
e94ecdd7b1 | ||
|
|
659b77719c | ||
|
|
7f556892ad | ||
|
|
cb76b95b01 | ||
|
|
319ec2f47f | ||
|
|
4b18f7ef2c | ||
|
|
1461c9fe27 | ||
|
|
5641c40958 | ||
|
|
0bee2c605e | ||
|
|
c472045ce4 | ||
|
|
6dc9265260 | ||
|
|
008e63524d | ||
|
|
cff3083a91 | ||
|
|
4d69aaee50 | ||
|
|
ee39024549 | ||
|
|
5810693e01 | ||
|
|
0722c4f00e | ||
|
|
4f2edebab6 | ||
|
|
ce6fbb4e39 | ||
|
|
cf39e72126 | ||
|
|
740bd48f42 | ||
|
|
18e579a8c6 | ||
|
|
cdab7a5e5f | ||
|
|
bd01199930 | ||
|
|
7ed739615e | ||
|
|
60e4d67772 | ||
|
|
7287cfeaec | ||
|
|
f03d72c880 | ||
|
|
6e90a96a15 | ||
|
|
b368678662 | ||
|
|
40b9a3f36b | ||
|
|
6fd80be243 | ||
|
|
855e85e7db | ||
|
|
7e7bf6db23 | ||
|
|
6f815678ec | ||
|
|
8ff74f56ee | ||
|
|
a3b708f9d4 | ||
|
|
9cb5e7bea3 | ||
|
|
16b57dcf1c | ||
|
|
1875470ac7 | ||
|
|
c955fd35f2 | ||
|
|
36b3ba6f45 | ||
|
|
039fa6d863 | ||
|
|
26ce9a5904 | ||
|
|
31e6ba00af | ||
|
|
89ae462610 | ||
|
|
afd08885fc | ||
|
|
536ac67f3c | ||
|
|
7b83893cf6 | ||
|
|
83c164a427 | ||
|
|
5fd8c6fc31 | ||
|
|
b733e41993 | ||
|
|
88669f6989 | ||
|
|
80373746f8 | ||
|
|
d2034f5031 | ||
|
|
ad2290cbb7 | ||
|
|
c870f08395 | ||
|
|
80fb4068d1 | ||
|
|
01cd734989 | ||
|
|
f44180d06c | ||
|
|
c7d18d426c | ||
|
|
0ec405f566 | ||
|
|
dd320c99c1 | ||
|
|
fc75d7b810 | ||
|
|
b3012b3133 | ||
|
|
ab6c2339a0 | ||
|
|
3eb9cc739c | ||
|
|
e9a1a75259 | ||
|
|
d69e1d33c3 | ||
|
|
8023e2e472 | ||
|
|
918ca371be | ||
|
|
9e3267fef4 | ||
|
|
6081e8dcc9 | ||
|
|
c801ff5047 | ||
|
|
8460132feb | ||
|
|
732f615a09 | ||
|
|
738abe5102 | ||
|
|
915311dcb6 | ||
|
|
c200427358 | ||
|
|
7b5dcf16a1 | ||
|
|
2db2414270 | ||
|
|
365b2eafe5 | ||
|
|
ea88499996 | ||
|
|
f4ff3e16d4 | ||
|
|
c67d5ae4bc | ||
|
|
00a087b1a4 | ||
|
|
a0f59826fe | ||
|
|
e7e281f10c | ||
|
|
93579cbc9f | ||
|
|
4ea02f25ac | ||
|
|
1a9a2762f3 | ||
|
|
b447c41f13 | ||
|
|
8e1fd0a30f | ||
|
|
cf6dc24193 | ||
|
|
c8fdd81d6a | ||
|
|
27868b6131 | ||
|
|
ef718042d2 | ||
|
|
194280af72 | ||
|
|
50fac36169 | ||
|
|
2595bb02db | ||
|
|
fe57022550 | ||
|
|
962ab5c290 | ||
|
|
6dbe1fe4c6 | ||
|
|
12f60897d9 | ||
|
|
b75bf197a3 | ||
|
|
613b8a57a3 | ||
|
|
af72364078 | ||
|
|
224f86ec08 | ||
|
|
fc9bf27560 | ||
|
|
d8d0b5f290 | ||
|
|
ce429e1caf | ||
|
|
248912f610 | ||
|
|
f646fa8573 | ||
|
|
0cf4639d95 | ||
|
|
0a52bf36f9 | ||
|
|
346146b4fa | ||
|
|
f00c3faeaa | ||
|
|
59e9bbf8a6 | ||
|
|
1af51ab172 | ||
|
|
7b55cfb190 | ||
|
|
476e2f961d | ||
|
|
979639342a | ||
|
|
356f7fe5bd | ||
|
|
6037ca184b | ||
|
|
8ef2f7e49f | ||
|
|
5091da6a60 | ||
|
|
d0a3972ca7 | ||
|
|
2ae2b2fd07 | ||
|
|
90c1f7a62b | ||
|
|
8fcb136d55 | ||
|
|
d6e3b377b4 | ||
|
|
2b3e325d63 | ||
|
|
4caf81c9e2 | ||
|
|
88661e1574 | ||
|
|
67ec3c8aeb | ||
|
|
59901b9e10 | ||
|
|
5a17b399a0 | ||
|
|
4c6b02a6b9 | ||
|
|
56bbb63afd | ||
|
|
00817766a6 | ||
|
|
16d67c8a87 | ||
|
|
45dc909e4c | ||
|
|
20fde1d652 | ||
|
|
38bbd917a9 | ||
|
|
f61142ee9a | ||
|
|
cf4554e7c8 | ||
|
|
271e7f0518 | ||
|
|
b5f37d6d46 | ||
|
|
e506309434 | ||
|
|
89a9cf24e9 | ||
|
|
7fc1ac98de | ||
|
|
fa53a0f4f9 | ||
|
|
0374de3fec | ||
|
|
401d130f03 | ||
|
|
61667aa03c | ||
|
|
cded20ae02 | ||
|
|
f9ec4964f7 | ||
|
|
1355a9244f | ||
|
|
ea16f9c2fe | ||
|
|
ae793414be | ||
|
|
7761435a81 | ||
|
|
8d16ce6e02 | ||
|
|
1ce8e263c3 | ||
|
|
a366898f65 | ||
|
|
bd0abf891c | ||
|
|
cf6c11852e | ||
|
|
26ac8167f3 | ||
|
|
47bf9c8f0b | ||
|
|
f97614433c | ||
|
|
d6bc1067f9 | ||
|
|
eb14c4afd3 | ||
|
|
6f2adfaef1 | ||
|
|
7f60071460 | ||
|
|
63b5edd901 | ||
|
|
bd3d50c6c6 | ||
|
|
592bfb4fa2 | ||
|
|
b94c820361 | ||
|
|
ab495854d5 | ||
|
|
bc97c4ddc3 | ||
|
|
d49fd9f6a4 | ||
|
|
800baca0ca | ||
|
|
1c1d3f17b7 | ||
|
|
66f8bb933d | ||
|
|
a8060fa71d | ||
|
|
4d6b2c6045 | ||
|
|
c331b2aeef | ||
|
|
264f2c51b2 | ||
|
|
ed71314986 | ||
|
|
bd7586a3b7 | ||
|
|
95cdad5c5d | ||
|
|
7172fd942d | ||
|
|
d4bb43a275 | ||
|
|
505d9156fe | ||
|
|
2801a89c1f | ||
|
|
b798c7a45d | ||
|
|
edb60d08b4 | ||
|
|
456d57b2de | ||
|
|
9a40ad18c7 | ||
|
|
bbd5e97bac | ||
|
|
10c63e02d5 | ||
|
|
c315297d7f | ||
|
|
5201f2ab6f | ||
|
|
7a3f5d7c34 | ||
|
|
a4af024f32 | ||
|
|
11eda33e58 | ||
|
|
f9bc0f69c4 | ||
|
|
7f520803ff | ||
|
|
8d9cca2fe5 | ||
|
|
a5f0484466 | ||
|
|
3dd3f71c97 | ||
|
|
20387fdb06 | ||
|
|
2618530afb | ||
|
|
0afead968a | ||
|
|
7597972eea | ||
|
|
a1eb545bef | ||
|
|
0df6e760e3 | ||
|
|
c38616b290 | ||
|
|
970b377b9a | ||
|
|
03c2f1f84a | ||
|
|
fe6b4da455 | ||
|
|
c4531b9757 | ||
|
|
0a958644b8 | ||
|
|
09cff17d72 | ||
|
|
c7384b10dd | ||
|
|
c7a552dfd5 | ||
|
|
044830b58a | ||
|
|
9cc9ff6d52 | ||
|
|
3d4179a756 | ||
|
|
67547fdf23 | ||
|
|
2882b83c21 | ||
|
|
7a80297a56 | ||
|
|
913958820c | ||
|
|
7c730b83d6 | ||
|
|
381256ed59 | ||
|
|
c0e0912eee | ||
|
|
902e71ee62 | ||
|
|
3764d48755 | ||
|
|
835e0f983d | ||
|
|
4dafe96c4b | ||
|
|
3d5bd6fb8b | ||
|
|
ad714bb59c | ||
|
|
6fcd63b923 | ||
|
|
cd7082454a | ||
|
|
2f571c2a98 | ||
|
|
cd2b5f6c48 | ||
|
|
f3fd38dfd2 | ||
|
|
f7e0dbc1ce | ||
|
|
359a607010 | ||
|
|
4653128a18 | ||
|
|
c289016324 | ||
|
|
c4662acdf0 | ||
|
|
bb8161e1c4 | ||
|
|
effdac91fd | ||
|
|
dc6bf34262 | ||
|
|
ae626ba1c8 | ||
|
|
b375fcc55c | ||
|
|
32e76b440f | ||
|
|
0d617d88c9 | ||
|
|
ce21cc2b9b | ||
|
|
633ab7abe3 | ||
|
|
fa476af70e | ||
|
|
c1a0eade38 | ||
|
|
ca9dc7794f | ||
|
|
c06aadb9a9 | ||
|
|
3bec2a5777 | ||
|
|
a6819ec7a5 | ||
|
|
833f94bf3a | ||
|
|
aad83072a8 | ||
|
|
8b999444b2 | ||
|
|
95ff0761b7 | ||
|
|
d2447f8aa6 | ||
|
|
530a5b998f | ||
|
|
1abf9fe340 | ||
|
|
03871f0342 | ||
|
|
89801236eb | ||
|
|
be07db3409 | ||
|
|
e9dd928859 | ||
|
|
6075028520 | ||
|
|
ec1107c65a | ||
|
|
db0d63b99f | ||
|
|
b3b21df409 | ||
|
|
721fa29b8d | ||
|
|
e72d0710e6 | ||
|
|
b88eab0bfe | ||
|
|
74e43aaa76 | ||
|
|
a6b7d09706 | ||
|
|
95289829ee | ||
|
|
daae86e1af | ||
|
|
50065ccc3e | ||
|
|
1889c9f048 | ||
|
|
64ed47165d | ||
|
|
3481620ed0 | ||
|
|
ba8d4b1b90 | ||
|
|
c1c21faf7e | ||
|
|
481f40378a | ||
|
|
52d790c654 | ||
|
|
0e7b888277 | ||
|
|
1e5286d36b | ||
|
|
d787f3ff97 | ||
|
|
45c7a4a59c | ||
|
|
670e271ff6 | ||
|
|
5243bbf0d4 | ||
|
|
1cbc0c6ed8 | ||
|
|
537fb97f48 | ||
|
|
9fffe7b590 | ||
|
|
282a12a90b | ||
|
|
cb84c88792 | ||
|
|
ef9409cee6 | ||
|
|
678143e7b9 | ||
|
|
ba1c8c2822 | ||
|
|
2ef02e2865 | ||
|
|
ff11f3b156 | ||
|
|
14f9534651 | ||
|
|
2c139e5c20 | ||
|
|
a48007a44d | ||
|
|
fb5cdf247d | ||
|
|
098df7f921 | ||
|
|
175d32f01e | ||
|
|
56d1b2a417 | ||
|
|
e7ffb44b45 | ||
|
|
bcba58f227 | ||
|
|
a26ef3fadd | ||
|
|
0871c8ea81 | ||
|
|
3cedcd1bc2 | ||
|
|
d4e33706cf | ||
|
|
2c4bd3eb2a | ||
|
|
6bbb79213b | ||
|
|
27f54c8e35 | ||
|
|
7efbbeed3f | ||
|
|
f00045502f | ||
|
|
0a4448dd70 | ||
|
|
9e9918b7f3 | ||
|
|
486fc34a4b | ||
|
|
1873018a2a | ||
|
|
632a7d15ba | ||
|
|
6f1860f5d0 | ||
|
|
129eb25078 | ||
|
|
3f02048bc5 | ||
|
|
abf3a4ae31 | ||
|
|
5c226237c0 | ||
|
|
af04fcfd3c | ||
|
|
5acd1e0e25 | ||
|
|
26afa6ea3a | ||
|
|
39bafb65c4 | ||
|
|
27d8a9ea71 | ||
|
|
2e25894eb5 | ||
|
|
70cba11fb7 | ||
|
|
d89752d543 | ||
|
|
7ab5f29f87 | ||
|
|
5e2cc7b56a | ||
|
|
2e9650d15f | ||
|
|
7cd39e278a | ||
|
|
e4745d501e | ||
|
|
d599bbccd9 | ||
|
|
5b5db0f8f5 | ||
|
|
009f9874c0 | ||
|
|
f629a4f373 | ||
|
|
9496db5171 | ||
|
|
d3eef19b95 | ||
|
|
7c0d5945ca | ||
|
|
6ffc5ba3d6 | ||
|
|
b7f5523874 | ||
|
|
d0c6c3e431 | ||
|
|
1a0b023658 | ||
|
|
daee4cd67f | ||
|
|
51a591e2b9 | ||
|
|
d84d485ec7 | ||
|
|
5fbcfa2b49 | ||
|
|
5a61e68a8c | ||
|
|
51dd38163d | ||
|
|
49ffdc6ab2 | ||
|
|
4a12de38cb | ||
|
|
d64c4cf1b4 | ||
|
|
d86147f406 | ||
|
|
9fa7190232 | ||
|
|
4f2d17f6ee | ||
|
|
05a6b6fc54 | ||
|
|
5b89fc200b | ||
|
|
620a625a72 | ||
|
|
1839150ecb | ||
|
|
2bf9b00022 | ||
|
|
4eb2ddef66 | ||
|
|
73e58517f6 | ||
|
|
bb37df19fc | ||
|
|
65a69b1dec | ||
|
|
cf264d5e81 | ||
|
|
d7950730dd | ||
|
|
274b888fd0 | ||
|
|
f0f908d9e1 | ||
|
|
ff7b1c547a | ||
|
|
0c4a0d18f4 | ||
|
|
5b7b904bde | ||
|
|
f5a7c7a833 | ||
|
|
6cbcca689d | ||
|
|
5ea70f0b03 | ||
|
|
b639d57c5b | ||
|
|
9dc278bd83 | ||
|
|
f3fcfe45da | ||
|
|
44e165d34e | ||
|
|
129432ae45 | ||
|
|
5916fca54d | ||
|
|
8af1b9ac4b | ||
|
|
7cea99d0ae | ||
|
|
05f40d7728 | ||
|
|
0d7d4fea61 | ||
|
|
dda343e398 | ||
|
|
13fda31e37 | ||
|
|
4833053a5c |
No files matched your search
@@ -0,0 +1,81 @@
|
|||||||
|
# 换行规范
|
||||||
|
#
|
||||||
|
# 背景:本仓库历史索引(index)内全部为 LF(`git ls-files --eol` 显示 i/lf),
|
||||||
|
# 而 Windows 工作区长期依赖 core.autocrlf=true 做 CRLF↔LF 转换。这种隐式转换
|
||||||
|
# 会让「工具以 LF 写盘」的改动在提交时产生大量换行噪声与 "LF will be replaced
|
||||||
|
# by CRLF" 警告。此处把规则显式化,使任何机器、任何 core.autocrlf 设置下行为一致。
|
||||||
|
#
|
||||||
|
# 注意:不要为已跟踪文件添加 `-text`。索引为 LF 而工作区为 CRLF 的文件一旦
|
||||||
|
# 被标记为 `-text`,git 会认为工作区内容与索引不一致,从而产生虚假改动。
|
||||||
|
|
||||||
|
# 默认:按内容自动识别是否为文本,文本一律在仓库内规范化为 LF。
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# 源码与配置(文本)
|
||||||
|
#
|
||||||
|
# 这里必须同时给出 `eol=crlf`。只写 `* text=auto` 时,工作区的换行形态仍由每个
|
||||||
|
# 开发者的 core.autocrlf 决定——工具(编辑器、脚本)以 LF 写盘时不会报错,只会
|
||||||
|
# 在提交时冒出 "LF will be replaced by CRLF" 警告,也就是本仓库此前的漂移来源。
|
||||||
|
# 声明 `eol=crlf` 后,git 会把工作区自动纠正为 CRLF,漂移自愈。
|
||||||
|
# 索引侧仍然是 LF,因此不会产生任何既有文件的改写。
|
||||||
|
*.php text eol=crlf
|
||||||
|
*.css text eol=crlf
|
||||||
|
*.scss text eol=crlf
|
||||||
|
*.js text eol=crlf
|
||||||
|
*.cjs text eol=crlf
|
||||||
|
*.mjs text eol=crlf
|
||||||
|
*.vue text eol=crlf
|
||||||
|
*.json text eol=crlf
|
||||||
|
*.html text eol=crlf
|
||||||
|
*.svg text eol=crlf
|
||||||
|
*.txt text eol=crlf
|
||||||
|
*.md text eol=crlf
|
||||||
|
*.yml text eol=crlf
|
||||||
|
*.yaml text eol=crlf
|
||||||
|
*.xml text eol=crlf
|
||||||
|
*.dist text eol=crlf
|
||||||
|
*.htaccess text eol=crlf
|
||||||
|
|
||||||
|
# gettext 目录按 LF 维护,避免与 xgettext/msgmerge 的默认输出冲突。
|
||||||
|
*.pot text
|
||||||
|
*.po text
|
||||||
|
|
||||||
|
# 构建产物必须声明为 LF。
|
||||||
|
#
|
||||||
|
# esbuild 恒定输出 LF,而上面的 `*.css eol=crlf` 会让工作区期待 CRLF,
|
||||||
|
# 于是每次执行 tools/build-public-assets.mjs 都会重新引入换行漂移。
|
||||||
|
# 这些路径必须放在上面各条之后:.gitattributes 以最后匹配的规则为准。
|
||||||
|
/assets/css/build/** text eol=lf
|
||||||
|
/assets/css/main.min.css text eol=lf
|
||||||
|
/assets/js/runtime/*.min.js text eol=lf
|
||||||
|
/assets/js/build/** text eol=lf
|
||||||
|
|
||||||
|
# 二进制资源(不做换行转换,也不做 diff)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.avif binary
|
||||||
|
*.ico binary
|
||||||
|
*.bmp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.eot binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.tgz binary
|
||||||
|
*.tar binary
|
||||||
|
*.pdf binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.webm binary
|
||||||
|
*.psd binary
|
||||||
|
*.ai binary
|
||||||
|
*.sketch binary
|
||||||
|
|
||||||
|
# 这些文件本身必须是 LF,否则 git 解析 `.gitattributes` 时可能把 CR 读进模式串。
|
||||||
|
.gitattributes text eol=lf
|
||||||
|
.gitignore text eol=lf
|
||||||
+13
-50
@@ -2,57 +2,19 @@
|
|||||||
/.git/
|
/.git/
|
||||||
/.claude/
|
/.claude/
|
||||||
/.compare/
|
/.compare/
|
||||||
|
/.zcode/
|
||||||
|
/.mimosa/
|
||||||
|
/.workbuddy/
|
||||||
|
|
||||||
# Project planning, internal docs, and local-only development files
|
# Project planning, internal docs, and local-only development files
|
||||||
|
/.aksrt-chrome-profile/
|
||||||
/.specs/
|
/.specs/
|
||||||
/docs/
|
/docs/
|
||||||
/tools/*
|
|
||||||
!/tools/build-public-assets.mjs
|
|
||||||
/tests/*
|
|
||||||
!/tools/wp-cli-visual-fixtures.php
|
|
||||||
!/tests/fixtures/
|
|
||||||
!/tests/visual/
|
|
||||||
/tests/fixtures/*
|
|
||||||
!/tests/fixtures/page-visual-matrix-v1.json
|
|
||||||
!/tests/widget-system/
|
|
||||||
/tests/widget-system/*
|
|
||||||
!/tests/widget-system/post-editor-reference-contract.test.cjs
|
|
||||||
!/tests/widget-system/post-visual-contract.test.cjs
|
|
||||||
!/tests/widget-system/lazyload-contract.test.cjs
|
|
||||||
!/tests/widget-system/toast-contract.test.cjs
|
|
||||||
!/tests/widget-system/page-display-contract.test.cjs
|
|
||||||
!/tests/widget-system/icon-card-contract.test.cjs
|
|
||||||
!/tests/widget-system/page-display-save-harness.php
|
|
||||||
!/tests/widget-system/admin-ui-contract.test.cjs
|
|
||||||
!/tests/widget-system/auth-source-contract.test.cjs
|
|
||||||
!/tests/widget-system/captcha-business-contract.test.cjs
|
|
||||||
!/tests/widget-system/captcha-security-harness.php
|
|
||||||
!/tests/widget-system/captcha-runtime-harness.test.cjs
|
|
||||||
!/tests/widget-system/oauth-security-contract.test.cjs
|
|
||||||
!/tests/widget-system/oauth-security-harness.php
|
|
||||||
!/tests/widget-system/user-badges-contract.test.cjs
|
|
||||||
!/tests/widget-system/user-features-contract.test.cjs
|
|
||||||
!/tests/widget-system/zibll-modern-widgets-contract.test.cjs
|
|
||||||
!/tests/widget-system/user-card-runtime-contract.test.cjs
|
|
||||||
!/tests/widget-system/user-card-runtime-harness.php
|
|
||||||
!/tests/widget-system/notification-preferences-contract.test.cjs
|
|
||||||
!/tests/widget-system/message-center-contract.test.cjs
|
|
||||||
!/tests/widget-system/message-center-url-harness.php
|
|
||||||
!/tests/widget-system/message-center-pagination-harness.php
|
|
||||||
!/tests/widget-system/asset-ledger-contract.test.cjs
|
|
||||||
!/tests/widget-system/financial-flow-contract.test.cjs
|
|
||||||
!/tests/widget-system/pay-orders-filter-contract.test.cjs
|
|
||||||
!/tests/widget-system/front-submit-editor-contract.test.cjs
|
|
||||||
!/tests/widget-system/comment-smilies-removal-contract.test.cjs
|
|
||||||
!/tests/widget-system/comment-children-list-reset-contract.test.cjs
|
|
||||||
!/tests/widget-system/pay-card-contract.test.cjs
|
|
||||||
!/tests/widget-system/circular-button-icon-margin-contract.test.cjs
|
|
||||||
!/tests/widget-system/frontend-lifecycle-contract.test.cjs
|
|
||||||
!/tests/asset-ledger/
|
|
||||||
!/tests/asset-ledger/*.php
|
|
||||||
!/tests/visual/docker-compose.zibll.yml
|
|
||||||
!/tests/visual/zibll-Caddyfile
|
|
||||||
!/tests/visual/README.md
|
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
ask-*.md
|
ask-*.md
|
||||||
@@ -60,11 +22,12 @@ ask-*.md
|
|||||||
assets/css/routes/docs/*
|
assets/css/routes/docs/*
|
||||||
!assets/css/routes/docs/toolbar-search-enhancements.css
|
!assets/css/routes/docs/toolbar-search-enhancements.css
|
||||||
|
|
||||||
# Frontend build output and installed dependencies
|
# Frontend build output and installed dependencies.
|
||||||
|
# assets/admin/build is deploy-only: build it on the target with
|
||||||
|
# cd assets/admin && npm ci && npm run build
|
||||||
|
/assets/admin/node_modules/
|
||||||
/assets/admin/build/*
|
/assets/admin/build/*
|
||||||
!/assets/admin/build/.gitkeep
|
!/assets/admin/build/.gitkeep
|
||||||
/assets/admin/node_modules/*
|
|
||||||
!/assets/admin/node_modules/.gitkeep
|
|
||||||
assets/admin/.vite-temp/
|
assets/admin/.vite-temp/
|
||||||
|
|
||||||
# PHP installed dependencies
|
# PHP installed dependencies
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# 用户公开主页通知按钮改为纯图标+未读角标
|
|
||||||
|
|
||||||
## 现状
|
|
||||||
- 目标按钮: `templates/parts/user/author-hero.php:144-149`(仅 `$is_own` 查看自己主页时输出),当前为铃铛图标 + "消息" 文字,无角标。
|
|
||||||
- 参照实现: 用户中心头部 `inc/helpers/user-center-renderers/profile-header.php:87-94` — `btn-ghost btn-sm btn-icon` 纯图标按钮,带 `aria-label`/`title`,未读数 > 0 时输出角标 `<span class="uc-header__action-badge">N</span>`(不封顶 99)。
|
|
||||||
- 未读数函数: `aksrt_get_user_unread_notification_count($user_id)`(`inc/modules/user-notifications.php:387`),模块经 `inc/bootstrap.php:78` 无条件加载,可直接调用。
|
|
||||||
- hero 卡片仅由 `page-user-home.php` 服务端静态渲染一次(DOM 无刷新只作用于 tab 面板),角标为静态服务端渲染,与用户中心行为一致。
|
|
||||||
- `.btn-icon` / `.btn-icon.btn-sm` 样式已存在于 `assets/css/core/buttons.css:129-136`,全局可用;但 `.uc-header__action-badge` 样式在 user-center 路由 CSS 中,不会在公开主页加载,需在 `assets/css/routes/user-home.css`(该页直接入队,无构建步骤)新增。
|
|
||||||
|
|
||||||
## 修改内容
|
|
||||||
|
|
||||||
### 1. `templates/parts/user/author-hero.php`
|
|
||||||
- 在第 58 行 `$messages_url` 之后新增:
|
|
||||||
```php
|
|
||||||
$unread_count = $is_own ? aksrt_get_user_unread_notification_count($user_id) : 0;
|
|
||||||
```
|
|
||||||
- 将消息按钮(144-149 行)整体替换为与用户中心一致的纯图标形式,类名沿用本文件 BEM 前缀:
|
|
||||||
```php
|
|
||||||
<a href="..." class="btn-ghost btn-sm btn-icon aksrt-author-hero__message-btn" aria-label="消息通知" title="消息通知">
|
|
||||||
<i class="fa fas fa-bell" aria-hidden="true"></i>
|
|
||||||
<?php if ($unread_count > 0) : ?>
|
|
||||||
<span class="aksrt-author-hero__action-badge"><?php echo esc_html((string) $unread_count); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
- 删除原 `<span>消息</span>` 文字输出,不保留旧结构。
|
|
||||||
|
|
||||||
### 2. `assets/css/routes/user-home.css`
|
|
||||||
在 `.aksrt-author-hero__follow-btn` 样式附近(约 167 行后)新增,视觉与用户中心角标(`user-center/shell.css:136-157`)完全一致:
|
|
||||||
```css
|
|
||||||
.aksrt-author-hero__message-btn {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-author-hero__action-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
right: -5px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-error, #ef4444);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
- `php -l templates/parts/user/author-hero.php`
|
|
||||||
- 运行 `node --test tests/widget-system/page-display-contract.test.cjs tests/widget-system/post-visual-contract.test.cjs`(已确认两者均未断言该按钮标记,应保持通过)
|
|
||||||
- 桌面/移动端检查: 图标按钮为 28px 方形(`.btn-icon.btn-sm`),角标绝对定位右上角,移动端居中布局不受影响(角标不占布局空间)
|
|
||||||
|
|
||||||
不写 function_exists 兜底、不保留文字版本、不添加迁移逻辑。
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
修复文章详情页分割线右侧三点图标不居中的问题
|
|
||||||
|
|
||||||
## 根因
|
|
||||||
|
|
||||||
三点按钮(`inc/helpers/aksrt-single-components.php:131-146`)为 `<button class="but cir post-drop-meta__toggle"><i class="fa fas fa-ellipsis-h"></i></button>`,位于 `templates/parts/single-author-bar.php` 中 `.single-meta-row` 分割线右端。`.but.cir` 是 `inline-flex; justify-content: center`,flex 居中的是子元素的 margin box。`assets/css/core/buttons.css` 中存在特异性冲突:
|
|
||||||
|
|
||||||
- 第 183 行 `.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }` 特异性 (0,3,1)
|
|
||||||
- 第 225 行 `.but.cir > .fa { margin: 0; }`(本意的重置规则)特异性 (0,3,0),被压掉
|
|
||||||
|
|
||||||
导致图标右侧残留 0.25em 外边距(15px 字号下约 3.75px),三点整体左偏约 1.9px。这是全局 bug,所有 `.but.cir` 图标按钮都受影响。
|
|
||||||
|
|
||||||
## 修改内容
|
|
||||||
|
|
||||||
1. **`assets/css/core/buttons.css:224-225`** — 加强重置规则的特异性到 (0,4,0),稳定压过 (0,3,1) 的 margin 规则(不依赖源码顺序):
|
|
||||||
```css
|
|
||||||
.but.cir svg.icon,
|
|
||||||
.but.cir > .fa,
|
|
||||||
.but.cir > .fa:not(.fa-angle-right),
|
|
||||||
.but.cir > i[class*="fa-"]:not(.fa-angle-right) { margin: 0; }
|
|
||||||
```
|
|
||||||
附一行注释说明 `:not()` 变体是为压过 `.but > i[class*="fa-"]:not(.fa-angle-right)` 的 margin 规则。保留原 `.but.cir > .fa` 选择器以继续覆盖 fa-angle-right 图标。不改 main.css(旧的手工捆绑副本):public 捆绑包中 core/buttons.css 副本排在 main.min.css 之后,高特异性重置必然胜出。
|
|
||||||
|
|
||||||
2. **重建捆绑包** — 运行 `node tools/build-public-assets.mjs`(esbuild 已就绪),重新生成 `assets/css/build/public.css/.min.css` 等捆绑文件。
|
|
||||||
|
|
||||||
3. **新增契约测试** `tests/widget-system/circular-button-icon-margin-contract.test.cjs`:断言源文件 buttons.css 与构建产物 public.min.css 中包含加强后的重置选择器(防止特异性回归),沿用现有 `*.test.cjs` 契约测试模式。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `node --test tests/widget-system/circular-button-icon-margin-contract.test.cjs` 通过
|
|
||||||
- 在构建后的 `public.min.css` 中确认重置规则存在且位于 margin 规则之后
|
|
||||||
- 刷新文章详情页目视确认三点图标在圆形按钮内水平居中(同时全站其他 `.but.cir` 图标按钮居中恢复正常)
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 第三方人机验证优化改造计划
|
|
||||||
|
|
||||||
约束:不写双路/兼容迁移代码;顺手清理死代码;不做真实视觉效果验收(用语法检查、契约测试、构建产物校验代替)。
|
|
||||||
已确认决策:范围全做(P0+P1+P2);下载场景 pass 放行 10 分钟;凭据不齐时仅加后台警告、前台保持放行。
|
|
||||||
|
|
||||||
## 改动文件总览
|
|
||||||
|
|
||||||
| 文件 | 动作 |
|
|
||||||
|---|---|
|
|
||||||
| `inc/modules/captcha.php` | 重写校验层 + 瘦身渲染 + 后台警告 + 静态缓存 |
|
|
||||||
| `assets/js/captcha-front.js` | 新建(从内联脚本迁移,~340 行) |
|
|
||||||
| `assets/css/shared/captcha.css` | 保留不动(改为进 bundle) |
|
|
||||||
| `tools/build-public-assets.mjs` | publicScripts 加 captcha-front.js;public.css 加 captcha.css |
|
|
||||||
| `src/Admin/Schema/SchemaPermissions.php` | 删除 'mode' 死字段(L32-34) |
|
|
||||||
| `assets/admin/src/schema/sections/permissions/index.js` | 删除 'mode' 死字段(L18 defaults、L50-56) |
|
|
||||||
| `src/Admin/SchemaProvider.php` | L403 白名单删 'mode';保存后 flush 配置静态缓存 |
|
|
||||||
| `tests/widget-system/captcha-cloud-contract.test.cjs` | 新建契约测试 |
|
|
||||||
|
|
||||||
不动的文件及原因:`aksrt-header-userbox.php`(toast-contract 测试锁定 signin 接线)、`data-aksrt-download-captcha` 挂载点(`templates/parts/resource-download-box.php:467`、`inc/helpers/aksrt-pay.php:1586`)、所有业务 JS(auth/signin/comments/cms-download/user-center/front-submit 只消费 `AKSRTCaptchaState.scenes[scene]` 与 mount 内 token input,接口不变即零改动)。
|
|
||||||
|
|
||||||
## 1. inc/modules/captcha.php — 校验层
|
|
||||||
|
|
||||||
**P0-1 腾讯云改走 WP HTTP API**:`aksrt_verify_tencent()` 删除 curl 块(L374-390,含 `CURLOPT_SSL_VERIFYPEER => false`),改用 `wp_remote_post('https://captcha.tencentcloudapi.com/', ['timeout'=>5, 'headers'=>aksrt_tencent_tc3_sign(...)生成的一维数组, 'body'=>$payload])`,与另外三家一致。签名函数不动。
|
|
||||||
|
|
||||||
**P1-5 错误信息收敛**:四家服务商的传输层错误(wp_remote_post 返回 WP_Error、响应不可解析)统一 `error_log('[AKSRT Captcha] {provider}: ' . 详情)` 后返回通用文案 `__('人机验证服务暂不可用,请稍后重试', 'aksrt')`;保留对用户有行动意义的映射文案(腾讯 CaptchaCode 表、阿里 F0xx 表)。腾讯 API Error 分支(签名错误等)也走 log+通用文案。
|
|
||||||
|
|
||||||
**P1-4**:阿里云 `SignatureNonce` 由 `uniqid('', true)` 改 `wp_generate_uuid4()`。
|
|
||||||
|
|
||||||
**P1-8**:四家 `'timeout'` 统一 5 秒(现 10 秒)。
|
|
||||||
|
|
||||||
**P1-6 Turnstile 绑定场景**:`aksrt_verify_captcha($post_data, $scene = '')` 增加第二参数(唯一调用方是 `aksrt_require_captcha`,无散落调用点);Cloudflare 成功响应校验:`action !== 'aksrt-verify'` 或 `cdata !== $scene`(存在才比对,lenient)→ 失败。客户端配套见 §2。
|
|
||||||
|
|
||||||
**P0-2 下载 pass 放行(10 分钟)**:`aksrt_require_captcha()` 内,scene 命中放行名单时先查 transient、验证通过后写 transient:
|
|
||||||
- `aksrt_captcha_pass_ttl($scene)`:仅 `download` 返回 `10 * MINUTE_IN_SECONDS`,其余返回 0(单一代码路径,无散落分支)
|
|
||||||
- key = `'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5(UA))`,仿 `aksrt_auth_rate_` 既有模式
|
|
||||||
|
|
||||||
## 2. assets/js/captcha-front.js — 新建(P2-9 + P1-7 客户端)
|
|
||||||
|
|
||||||
把 `aksrt_render_captcha()` 内联 `<script>`(L632-969)整体迁移为静态文件,逻辑不改名不重写:`loadScriptOnce`、四家 `load*`、`AKSRTCaptchaState.scenes[scene].verify/reset` 注册、`setField/resetVerificationState/syncDownloadSiblings` 等原样搬移。仅两处行为修正:
|
|
||||||
- 极验 `onClose` 空回调改为 `cancelPending('已取消验证')`(P1-7,与腾讯/CF 取消体验对齐)
|
|
||||||
- Turnstile render 配置加 `cdata: C.scene`(P1-6 客户端配套)
|
|
||||||
|
|
||||||
激活模型:扫描 `document.querySelectorAll('[data-aksrt-captcha-config]')`,`JSON.parse(el.getAttribute(...))` 逐个 init;DOMContentLoaded 后再补扫一次并暴露 `window.AKSRTCaptchaState.rescan()` 供未来 ajax 注入使用。保持 vanilla JS、无新依赖。
|
|
||||||
|
|
||||||
对应 PHP 渲染端瘦身:`aksrt_render_captcha()` 只输出
|
|
||||||
1. 原 wrapper 结构不变(`.aksrt-captcha-wrap` + `#uid` box + `#vid` captcha_verified 隐藏域),
|
|
||||||
2. wrapper 上挂 `data-aksrt-captcha-config`(`esc_attr(wp_json_encode($js_config))`),
|
|
||||||
3. 删除内联 `<script>` 与逐次 `<link rel="stylesheet">`。
|
|
||||||
|
|
||||||
TOKEN_FIELDS 常量、i18n 字符串原样进 JS 文件(与仓库其它 bundle 内硬编码中文一致)。
|
|
||||||
|
|
||||||
## 3. 构建与后台(P2-10 + bundle 接线)
|
|
||||||
|
|
||||||
- `tools/build-public-assets.mjs`:`publicScripts` 追加 `'assets/js/captcha-front.js'`;`cssBundles['public.css']` 追加 `'assets/css/shared/captcha.css'`。走既有 bundle 版本机制,无需新增 enqueue 代码。
|
|
||||||
- `SchemaPermissions.php`:删除 'mode' 字段定义(L32-34,单选项 always 的死字段)。
|
|
||||||
- `assets/admin/src/schema/sections/permissions/index.js`:删除 `mode` 的 defaults(L18)与字段定义(L50-56)。
|
|
||||||
- `SchemaProvider.php` L403:归一化白名单去掉 `'mode'`;`write_section_settings()` 更新 `aksrt_captcha_config` 后调用新助手 `aksrt_captcha_config_reset()` 清静态缓存(function_exists 守卫,与现有 captcha 专用块并排)。
|
|
||||||
- DB 里残留的 `mode` 键不做迁移清洗(读路径白名单已忽略它,符合"不做迁移"约束)。
|
|
||||||
|
|
||||||
## 4. inc/modules/captcha.php — 后台警告与缓存
|
|
||||||
|
|
||||||
- **P0-3**:新增顶层 `add_action('admin_notices', ...)`:`manage_options` 权限 + `enabled` 为真 + `active_provider` 非空 + `aksrt_captcha_provider_is_configured()` 为假时,输出 `notice-error` 横幅,文案指向「功能&权限 → 人机验证」补全配置(复用现有 misconfig 提示文案)。前台行为不变(保持放行)。
|
|
||||||
- **P2-11**:`aksrt_get_captcha_config()` 加 `static $cache`;新增 `aksrt_captcha_config_reset()`;写入路径(SchemaProvider)调用 reset。`aksrt_get_captcha_status_summary()` 的 12 场景循环随之受益。
|
|
||||||
|
|
||||||
## 5. 新契约测试 tests/widget-system/captcha-cloud-contract.test.cjs
|
|
||||||
|
|
||||||
仿 toast-contract 的正则断言风格:
|
|
||||||
- `captcha.php`:不匹配 `curl_init|CURLOPT_SSL_VERIFYPEER|machine-verification|slidercaptcha|imagecaptcha`;匹配 `wp_remote_post`(≥4 处)、`wp_generate_uuid4`、`'timeout'\s*=>\s*5`、`aksrt_captcha_pass_`;渲染函数不匹配 `/<script|<link/`(内联抽取完成)
|
|
||||||
- `captcha-front.js` 存在且含 `AKSRTCaptchaState`、`data-aksrt-captcha-config`
|
|
||||||
- `build-public-assets.mjs` 的 publicScripts 含 captcha-front.js、public.css 含 shared/captcha.css
|
|
||||||
- `SchemaPermissions.php` 与 admin `index.js` 的 captcha 段不匹配 `mode`
|
|
||||||
- `SchemaProvider.php` 不匹配 `'mode'`
|
|
||||||
|
|
||||||
## 6. 验证(不做视觉验收)
|
|
||||||
|
|
||||||
1. `php -l` 全部改动的 PHP 文件
|
|
||||||
2. `node --check` captcha-front.js
|
|
||||||
3. `node tools/build-public-assets.mjs` 重建 bundle
|
|
||||||
4. `grep` 全仓清扫:无 `curl_init`、无 `'mode'`(captcha 相关文件)、无 legacy 标记
|
|
||||||
5. `node --test tests/widget-system/*.test.cjs` 全量跑,预期仅存量 page-display-contract 一项失败(与本次无关,已验证在 HEAD 上同样失败)
|
|
||||||
|
|
||||||
## 明确不做
|
|
||||||
|
|
||||||
- 不做视觉验收/浏览器实测(按要求)
|
|
||||||
- 不写新旧配置迁移、不清洗 DB 残留 `mode` 键
|
|
||||||
- 不动腾讯 trerror 容灾票据策略(维持拒绝)
|
|
||||||
- 不做阿里云签名 V3 升级、Turnstile appearance/retry 配置项(列为后续可选项)
|
|
||||||
- 不动 `ExtensionController` 的 extensions 状态端点(SPA 未消费属另一话题)
|
|
||||||
|
|
||||||
## 建议提交拆分(如需提交)
|
|
||||||
|
|
||||||
1. `fix(captcha): use WP HTTP API with TLS verification for tencent`
|
|
||||||
2. `feat(captcha): short-lived download pass + turnstile binding + error hardening`
|
|
||||||
3. `refactor(captcha): extract inline runtime to captcha-front.js bundle`
|
|
||||||
4. `chore(captcha): drop dead mode field, config cache, contract tests`
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
## 现状结论(影响方案的既有事实)
|
|
||||||
|
|
||||||
- 下载系统集中在 `src/Content/Download.php`:路由前缀硬编码为常量 `ENDPOINT='aksrt-dl'`(同时用于 rewrite 正则 :68 和 URL 拼接 :226)、TTL 硬编码 `TOKEN_TTL=600`(:12)、`max_uses` 固定写死 1(:422)、`expires_in` 返回固定值(:242)。
|
|
||||||
- 扣次在"点击兑换"时发生(`Download.php:286-292`),无 HEAD 特判、无断开检测,需按需求重构。
|
|
||||||
- 过期/耗尽已是 `fail_410()`(HTTP 410 + wp_die 提示"回到资源页重新获取"),**不自动生成新链接** —— 该行为现状已满足,保持不变。
|
|
||||||
- `aksest_dl_token` 表(`src/Core/DBMigratorPart2.php:133-150`)的 token 列已是 `PRIMARY KEY`(即唯一索引),重复密钥不可能入库;需补的是 issue_token 插入失败时的换号重试。
|
|
||||||
- 设置体系是双端 schema:PHP 权威在 `src/Admin/Schema/*.php`,Vue 镜像在 `assets/admin/src/schema/sections/**`,菜单在 `assets/admin/src/constants/sections/permissions.js`,REST 用泛型路由自动生效。"功能&权限"= SPA 内 permissions 域。
|
|
||||||
- `Auth\Router::maybe_flush_rewrite_rules()`(:78-84) 已有"版本标记式 flush_rewrite_rules()"先例,可直接套用。
|
|
||||||
|
|
||||||
## 一、新增设置分区(permissions 域 → "下载配置")
|
|
||||||
|
|
||||||
存储为独立 option `aksrt_download_config`(沿用 captcha/moderation 的 standalone 模式),三个字段:
|
|
||||||
|
|
||||||
| key | 类型 | 默认 | 校验 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `url_alias` | input | `download` | 必填;trim 后非空;仅允许 `[A-Za-z0-9_-]`(禁中文/空格// \ ? # & 等);≤48 字符;拒绝保留字黑名单(wp-json、wp-admin、wp-content、wp-includes、admin、login、logout、register、lostpassword、feed、search、author、page、comments、sitemap、sign 等,防路由冲突/404) |
|
|
||||||
| `default_max_uses` | number | `1` | min 1 / max 100 |
|
|
||||||
| `ttl_minutes` | number | `10` | min 1 / max 1440 |
|
|
||||||
|
|
||||||
修改文件:
|
|
||||||
1. **`src/Admin/Schema/SchemaPermissions.php`**:新增 `download` section(version 1.0.0,option_key `aksrt_download_config`,单组 basic 三字段,含中文标签/help 说明"完整下载才计次、到期或耗尽后 410 需重新获取")。
|
|
||||||
2. **`src/Admin/SchemaProvider.php` `validate()`**:仿照 payment/withdrawal 特例先例(:269-292),对 `permissions/download` 增加中文错误消息校验块(空值/非法字符/过长/保留字)——422 错误经前端 `message.error('保存失败: …')` 弹窗呈现,满足"后台弹窗提示"。
|
|
||||||
3. **`src/Settings/Settings.php`**:`STANDALONE_OPTION_KEYS` 追加 `aksrt_download_config`(导入导出完整性 + 缓存失效覆盖)。
|
|
||||||
4. **`assets/admin/src/schema/sections/permissions/index.js`**:镜像新增 `download` 分区(defaults 与 PHP 完全一致)。
|
|
||||||
5. **`assets/admin/src/constants/sections/permissions.js`**:追加子页 `schemaPage({ key:'permissions-download', route:'/permissions/download', label:'下载配置', section:'download' })`,置于附件配置之后。泛型 REST 路由与 SchemaPage 无需改动。
|
|
||||||
|
|
||||||
## 二、Download.php 改造(`src/Content/Download.php`)
|
|
||||||
|
|
||||||
1. **动态前缀**:删掉 `const ENDPOINT`;新增私有 `endpoint()` 读配置(无效/被污染时回落 `download`);`register_rewrite()` 用 `preg_quote($endpoint)` 拼正则(:66-72);URL 拼接(:226)同源。
|
|
||||||
2. **别名变更刷新路由**:仿 `Auth\Router` 版本标记法——`init` 尾部挂 `maybe_flush_rewrite()`(priority 99):比较 `AKSRT_VERSION + 当前别名` 与 option `aksrt_dl_rewrite_version`,不一致则 `flush_rewrite_rules(false)` 并回写。导入/重置/直接改库都覆盖,无双路兼容逻辑。
|
|
||||||
3. **动态 TTL / 次数**:删掉 `TOKEN_TTL` 常量引用;`issue_token()` 写入 `max_uses = 配置.default_max_uses`、`expires_at = now + 配置.ttl_minutes*60`;`rest_request` 的 `expires_in` 改为动态秒数。配置读取做 clamp 兜底(别名非法回落 download、次数 1..100、分钟 1..1440)。
|
|
||||||
4. **完整下载才扣次**:
|
|
||||||
- 将扣次(UPDATE used_count)与文章计数 `_aksrt_res_download_count`(:286-300) 移到交付成功之后的新方法 `consume_token()`;UPDATE 加原子条件 `WHERE token=%s AND used_count < max_uses`,并发下计数封顶不超发。
|
|
||||||
- `stream_local_file()` 改为返回是否完整送达:输出循环中 `connection_aborted()` 为真立即中断并返回 false;收尾再核验一次。仅当完整送达且非 HEAD 才 `consume_token()`——网络波动中断不消耗次数,用户可重新点同一链接续下。
|
|
||||||
- **HEAD 探测**:`REQUEST_METHOD === 'HEAD'` 时照常走校验/输出 headers 但不扣次。
|
|
||||||
- **外链 302 源**(远端主机无法感知"是否下完"):把 302 交接视为一次交付,重定向发出后扣次(HEAD 时同样跳过)。此为唯一可实现语义,计划中明示。
|
|
||||||
- 过期/耗尽仍走现有 `fail_410()`(改动前后行为一致)。
|
|
||||||
5. **密钥冲突重试**:`issue_token()` 插入失败时用全新随机串重试至多 3 次(PRIMARY KEY 已保证唯一,此为极低概率兜底;表缺失仍在入口提前返回 WP_Error 不进循环)。
|
|
||||||
|
|
||||||
## 三、清理与文档
|
|
||||||
|
|
||||||
- 删除不再使用的 `Download::ENDPOINT` / `Download::TOKEN_TTL` 常量及全部残留引用(实现时全库 grep 复核;已知其余命中仅为 CSS 类名巧合,不动)。
|
|
||||||
- 更新 `CLAUDE.md:132` 文档行:rewrite 前缀改为"可配置别名(默认 download)",并补充"完整送达才计次"契约。
|
|
||||||
- 不写任何迁移/双路:现有表结构不动,存量过期 token 由每日 cron 自然清理。
|
|
||||||
- **关于唯一索引**:PRIMARY KEY(token) 本身即唯一索引且已存在(不加冗余 UNIQUE KEY,也不做表结构变更——与"不写迁移"一致),以插入重试落实需求的防冲突意图。
|
|
||||||
|
|
||||||
## 四、验证
|
|
||||||
|
|
||||||
1. 新增 Node 契约测试 `tests/widget-system/download-config-contract.test.cjs`:断言双端 schema 的字段/默认值/option_key 一致、菜单子项注册、Download.php 关键契约(动态 endpoint、流式完成后才条件 UPDATE 计次、HEAD 跳过、410 不自动续签、无 ENDPOINT/TOKEN_TTL 常量残留)。
|
|
||||||
2. `node --test tests/widget-system/download-config-contract.test.cjs` + 跑现有相关契约测试确认无回归。
|
|
||||||
3. `php -l` 全部改动的 PHP 文件;`php tools/audit-admin-contracts.php` 通过双端合同审计。
|
|
||||||
4. `cd assets/admin && npm ci && npm run build`,确认 build 产物完整(PHP 端有 chunk 缺失探测)。
|
|
||||||
5. 视觉不做真实验收(按约定跳过 Docker 对比环境)。
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 评论区 Zibll 1:1 全量复刻计划
|
|
||||||
|
|
||||||
目标:将 AKSRT 评论区渲染层(DOM、类名、布局、CSS、交互行为)替换为与 Zibll 完全一致,不保留旧类名、不写兼容/迁移代码。现有 REST 后端(`src/Core/CommentActions.php`)保留,仅前端选择器随之更新。
|
|
||||||
|
|
||||||
## 参考源(Zibll 侧,只读对照)
|
|
||||||
- `zibll/template/comments.php`(整体结构 L21–210)
|
|
||||||
- `zibll/inc/functions/zib-comments-list.php`(`zib_comments_list` 回调 L14、分页 L569–678)
|
|
||||||
- `zibll/inc/functions/functions.php` L1775 `zib_get_input_expand_but()`(表情/代码/图片/链接/快捷回复工具栏)
|
|
||||||
- `zibll/css/main.css` L9411–9660(桌面端)+ L12470–12560(移动端)
|
|
||||||
- `zibll/js/comment.js`(交互行为参照)
|
|
||||||
- `zibll/img/smilies/*.gif`(约 100 个表情资源)
|
|
||||||
|
|
||||||
## 改动清单
|
|
||||||
|
|
||||||
### 1. 评论列表项 DOM — `inc/helpers/comments/item.php` 重写
|
|
||||||
输出 Zibll 原始结构(去掉所有 `aksrt-comment__*` 类):
|
|
||||||
```
|
|
||||||
li.comment#comment-{id}(用 WP comment_class(),含 depth-N)
|
|
||||||
└ ul.list-inline > li.comt-main#div-comment-{id}
|
|
||||||
├ div.comment-header.mb10 > div.author-box.flex.ac
|
|
||||||
│ ├ span.avatar-img.comt-avatar > img.avatar(含嵌套 ul.avatar-icontag 徽章位)
|
|
||||||
│ ├ <name>(置顶 badge.badge-topping.jb-blue + a.text-ellipsis.font-bold / b.mr6 + 作者 badg)
|
|
||||||
│ └ a.action.action-comment-like.muted-2-color.flex0.ml10(svg like + <count>)
|
|
||||||
└ div.comment-footer
|
|
||||||
├ div.mb10.comment-content#comment-content-{id}
|
|
||||||
└ div.comt-meta.muted-2-color
|
|
||||||
├ span.comt-author(time-ago,title=完整时间)· IP 属地 badg · span @父级作者
|
|
||||||
├ span.badge-approve > span.badg.c-red.badg-sm「待审核」
|
|
||||||
├ span.reply-link > a.comment-reply-link(data-commentid / data-postid)
|
|
||||||
└ span.dropdown.drop-fixed-sm.padding-6(menu_2 ⋮ 菜单:置顶/编辑/通过/删除,wp-ajax-submit 类)
|
|
||||||
子级:li.comment > ul.children > li.comment…
|
|
||||||
```
|
|
||||||
保留 Zibll 的自定义语义标签 `<name>` `<badge>` `<text>` `<count>` 以确保 CSS 选择器 1:1 命中。置顶/点赞/神评等数据仍取自现有 `inc/helpers/comments/actions.php`。
|
|
||||||
|
|
||||||
### 2. 评论区外壳与表单 — `inc/helpers/aksrt-comments.php` + `comments/form-parts.php` 重写
|
|
||||||
- 外壳:`div.theme-box#comments` > `div.box-body.notop > div.title-theme`(+ `<small>共N条</small>`/抢沙发)> `div.no_webshot.main-bg.theme-box.box-body.radius8.main-shadow`
|
|
||||||
- 登录门槛:`div.comment-signarea.text-center` > h3 + 登录/注册按钮 + 社交登录(复用现有 oauth 输出,仅换外壳类名)
|
|
||||||
- 移动端假输入:`div.flex.ac.jsb.virtual-input`(`fixed-input="#respond"`)
|
|
||||||
- 表单 `div#respond.mobile-fixed > form#commentform`:
|
|
||||||
- `div.flex.ac > div.comt-title.text-center.flex0.mr10`(comt-avatar 60px + 昵称)+ `div.comt-box.grow1`
|
|
||||||
- `div.action-text.mb10.em09.muted-2-color`(回复@xxx)
|
|
||||||
- `textarea.form-control.grin#comment`(Ctrl+Enter 提交)
|
|
||||||
- `div.comt-ctrl.relative`:comt-tips(隐藏字段+nonce)、comt-tips-right(`a.but.c-red#cancel-comment-reply-link` 取消 + `button.but.c-blue.pw-1em.comment-send#submit`)、comt-tips-left(`span.dropup#comment-user-info` 游客姓名/邮箱/网址 line-form 下拉 + 工具栏)
|
|
||||||
- 工具栏按 Zibll `zib_get_input_expand_but()` 结构:`span.dropup.relative > a.but.btn-input-expand.input-{smilie|code|image|link|quick}.mr6` + `div.dropdown-menu`(表情面板 `div.dropdown-smilie.scroll-y.mini-scrollbar > a.smilie-icon[data-smilie] > img`),按现有设置项开关
|
|
||||||
- 列表:`div#postcomments > ol.commentlist.list-unstyled`,过滤器 `div.comment-filter.tab-nav-theme.flex.ac.jsb`(最新/最热 + 只看作者)放在 commentlist 之前、#postcomments 内
|
|
||||||
- 分页:AJAX 模式 `div.theme-pagination.lazyload.ias-pagenav.pagenav > div.order-ajax-next > a.ias-btn 加载更多`;数字模式 `div.pagenav` + paginate_links(>8 页带 `a.pag-jump` 跳页 input)
|
|
||||||
- 空状态:`zib_get_null()` 同款 `img/null.svg` 结构
|
|
||||||
|
|
||||||
### 3. CSS 全量移植 — `assets/css/frontend/comments/`
|
|
||||||
- 把 `zibll/css/main.css` L9411–9660 与移动端 L12470–12560 规则逐条移植为新文件(按现有 shell/list/form/mobile 拆分或单文件 `zibll-comment.css`),选择器保持原样(`#comments .comt-title`、`#postcomments .commentlist > .comment > .list-inline .comment-footer {margin-left:65px; margin-top:-25px}`、`.children {margin-left:86px}`、`.children .children {margin-left:23px}`、`.smilie-icon {width:30px!important}` 等)
|
|
||||||
- 依赖的通用变量(`--main-shadow/--main-border-color/--main-radius/--main-bg-color`)与基础类(`.avatar-img`、`.but`、`.badg`、`.line-form`、`.pagenav`)核对 AKSRT 是否已有;缺的补进对应基础样式文件,值与 Zibll 一致
|
|
||||||
- **删除** 现有 `comments/shell.css`、`comments/mobile-item.css`、`comment-tools.css` 中所有旧评论样式与 `--aksrt-comment-*` 变量,无兼容层
|
|
||||||
- 保持 enqueue 结构(`src/Core/ScriptsPart1b.php` / `ScriptsPart1c.php`)指向新文件
|
|
||||||
|
|
||||||
### 4. JS 选择器对齐
|
|
||||||
- `assets/js/core/comments.js`、`comment-ajax.js`、`comment-tools.js`、`comment-fixed-input.js`、`comment-move-form.js`:全部选择器改为 Zibll 目标(`.commentlist`、`.comment-reply-link`、`#div-comment-{id}`、`.comment-send`、`#respond.mobile-fixed`、`[fixed-input]` 等),提交后的局部插入逻辑改为 Zibll 同款(prepend 到 `.children` 或 `.commentlist`,`slideDown`,15s 冷却倒计时按钮禁用)
|
|
||||||
- REST 端点与 nonce 机制不变;AJAX 分页/排序沿用现有 fragment 管线,仅换容器选择器
|
|
||||||
|
|
||||||
### 5. 资源(按用户确认)
|
|
||||||
- **删除 `assets/img/smilies/` 中现有全部旧表情包资源**,从 `zibll/img/smilies/` 拷贝全套 gif 替换;`content.php` 的 `[g=]` 转换与 data-smilie 表情面板统一指向该目录的 Zibll 文件名列表
|
|
||||||
- 空状态 `null.svg`、点赞/置顶/菜单等 svg 图标按 Zibll 形状补齐到 `assets/img/`
|
|
||||||
|
|
||||||
### 6. 清理与验证
|
|
||||||
- 全库 grep `aksrt-comment__`、`--aksrt-comment-`、`.comments-area`(评论语境)确保零残留(用户中心页的评论条目不在本次范围)
|
|
||||||
- 检查 `tests/widget-system/post-visual-contract.test.cjs` 是否引用旧评论类名,如有则同步更新契约
|
|
||||||
- `php -l` 校验全部改动 PHP 文件;运行 `node --test tests/widget-system/post-visual-contract.test.cjs`
|
|
||||||
- 桌面+移动端断点目测核对 Zibll 截图/本地对照环境(`tests/visual/README.md`)
|
|
||||||
|
|
||||||
## 不做的事
|
|
||||||
- 不写任何双路渲染、旧类名兼容或数据迁移代码
|
|
||||||
- 不动 `src/Core/CommentActions.php` REST 逻辑、管理后台 Vue 设置页(除非契约字段需对齐)
|
|
||||||
- 不动论坛/商铺评论(Zibll 中亦为独立模块)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
## 目标
|
|
||||||
|
|
||||||
在后台「AKSRT → 功能&权限 → 下载配置」中新增总开关 `enabled`(switch,**默认开启**)。开启 = 现有本站 token 中转下载(`/download/{token}`,别名/次数/有效期生效);关闭 = 直连模式,下载按钮直接渲染原始下载地址,不再生成本站中转链接。**权限门控保留**(未登录/未购买/非会员仍显示解锁提示,已确认)。不写迁移、不留死代码、不做真实视觉效果验收。
|
|
||||||
|
|
||||||
## 现状(调研结论)
|
|
||||||
|
|
||||||
- 「下载配置」是 SPA 通用 schema 页(`SchemaPage.vue`),PHP schema 在 `src/Admin/Schema/SchemaPermissions.php:73-85`(option `aksrt_download_config`),前端镜像在 `assets/admin/src/schema/sections/permissions/index.js:246-269`
|
|
||||||
- 下载链路:按钮 `data-action="aksrt-download"` → `POST /aksrt/v1/download/request`(`src/Content/Download.php:215`)签发 token → 返回 `home_url('/别名/{token}')` → rewrite + `maybe_serve_download()` 中转(本地流式/302 外链)
|
|
||||||
- 发现死代码:`rest_policy()` + `/download/policy` 路由(全库无调用方)、`aksrt_is_download_alone_page_enabled()`(`inc/helpers/aksrt-pay.php:921`,无调用方)
|
|
||||||
|
|
||||||
## 变更清单
|
|
||||||
|
|
||||||
**1. `src/Admin/Schema/SchemaPermissions.php`(PHP 权威 schema)**
|
|
||||||
- download section version 1.0.0 → 1.1.0
|
|
||||||
- groups 重构(仿 login/moderation 模式):新 `basic` 组「下载总开关」,字段 `enabled`(switch,default true,help 说明「关闭后下载采用直连,下方链接规则不生效」);原 `url_alias`/`default_max_uses`/`ttl_minutes` 移入 `rules` 组「链接规则」,各加 `dependsOn: {key: 'enabled', value: true}`
|
|
||||||
|
|
||||||
**2. `assets/admin/src/schema/sections/permissions/index.js`(前端镜像)**
|
|
||||||
- `download.defaults` 增加 `enabled: true`;groups 同步 PHP 双组结构(switch + dependsOn);更新 description
|
|
||||||
|
|
||||||
**3. `src/Content/Download.php`**
|
|
||||||
- 新增 `public function is_enabled(): bool`:读 `aksrt_download_config`,`enabled` 键缺失视为开启(默认 true),否则 `!empty()` 判定(与 PostDisplay 等现有默认 true 惯用法一致)
|
|
||||||
- 关闭时提前 return:`register_rewrite()`(不注册路由)、`register_routes()`(不注册 `/download/request`)、`maybe_serve_download()`(旧 token 链接失效,含 `?aksrt_dl_token=` 直查)
|
|
||||||
- **删除死代码**:`rest_policy()` 方法与 `/download/policy` 路由注册
|
|
||||||
- token 签发/流式输出等开启路径逻辑不动(现有契约测试正则不受影响)
|
|
||||||
|
|
||||||
**4. `templates/parts/resource-download-box.php`**
|
|
||||||
- 顶部计算 `$direct_download`(`class_exists` 守卫 + `is_enabled()` 取反)
|
|
||||||
- 下载源循环:直连模式且非 need_login 时,渲染 `<a href="{原始url}" target="_blank" rel="nofollow noopener">`(保留网盘识别徽章/着色),提取码旁复用全局 clipboard 委托渲染复制按钮(`data-clipboard-text`,main.min.js 已全局处理);need_login 分支不变
|
|
||||||
- 直连模式下 `$captcha_active` 置 false(验证码容器成死 UI)
|
|
||||||
- 独立下载页入口(`down_alone_page`)是独立功能,不与本开关耦合
|
|
||||||
|
|
||||||
**5. `inc/helpers/aksrt-pay.php`**
|
|
||||||
- `aksrt_get_paid_download_actions_html()`:直连模式渲染 `<a class="but ...">` 直链替代 token 按钮;提取码/复制按钮逻辑不变
|
|
||||||
- **删除死代码** `aksrt_is_download_alone_page_enabled()`
|
|
||||||
|
|
||||||
**6. `tests/widget-system/download-config-contract.test.cjs`(扩展契约测试)**
|
|
||||||
- 字段对齐测试加入 `enabled => true`(PHP)/ `enabled: true`(JS)
|
|
||||||
- 新增断言:Download.php 含 `is_enabled` 守卫;`/download/policy` 已移除;直连模式下盒模板与付费卡函数含直链 `<a>` 渲染分支
|
|
||||||
|
|
||||||
## 行为语义(写入代码注释与测试预期)
|
|
||||||
|
|
||||||
- 默认开启,存量站点零感知(不迁移 option,键缺失即 true)
|
|
||||||
- 关闭后:无下载计次、无免费额度扣减、无下载就绪通知、无验证码——直连语义的自然结果
|
|
||||||
- 关闭后旧 `/download/{token}` 链接全部失效
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `php -l` 检查所有改动的 PHP 文件
|
|
||||||
- `node --test tests/widget-system/download-config-contract.test.cjs`,并跑 pay-card-contract、post-visual-contract 确认无回归
|
|
||||||
- `cd assets/admin && npm run build` 重建 SPA(前端 schema 变更需构建生效);若 `assets/admin/build/` 被 git 跟踪则一并纳入变更
|
|
||||||
- 按要求跳过真实视觉效果验收
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
## 目标
|
|
||||||
|
|
||||||
把"点关注"从同步跑完整结算链(积分+勋章×3遍+等级,约几十条 SQL)优化为:接口只写关注表立即返回,结算在响应送达客户端之后执行。
|
|
||||||
|
|
||||||
## 改动内容(3 个文件)
|
|
||||||
|
|
||||||
### 1. `src/User/Metrics.php` — 删除冗余订阅
|
|
||||||
`on_user_followed()`(Metrics.php:93-98)只做 `reevaluate_medals(following_id)`,而 `BadgeAutoAward::on_follow` 已经评估过该用户——这是同一请求内的第 3 遍重复评估。删除该方法和它的 `add_action` 注册(Metrics.php:34)。`reevaluate_medals` 方法本身保留(点赞/浏览等其他钩子仍在用)。
|
|
||||||
|
|
||||||
### 2. `src/User/BadgeAutoAward.php` — 删除对"关注者"的无效评估
|
|
||||||
`on_follow()`(BadgeAutoAward.php:61-68)同时评估关注者和被关注者,但 `Badges::metric_value` 的全部指标分支(registration_time/checkin/new_post/comment/**followed(被关注数)**/like/views/pay_income/pay_rebate)没有任何一个依赖"关注了多少人"——关注者评估必然零产出。改为只评估 `$following_id`(保留双参数签名以兼容钩子,`unset($follower_id)`)。
|
|
||||||
|
|
||||||
### 3. `src/User/Follow.php` — 结算钩子延迟到响应之后
|
|
||||||
`follow()` 里的 `do_action('aksrt_user_followed', ...)`(Follow.php:50)改为经 `register_shutdown_function` 延迟执行:关闭回调里先调 `fastcgi_finish_request()`(PHP-FPM 下存在)把响应立即送达客户端,再触发钩子跑积分/勋章/等级结算。新增一个 `private static function defer_action(string $hook, int ...$args)` 辅助方法。
|
|
||||||
|
|
||||||
- 非-FPM 环境(无 `fastcgi_finish_request`):退化为 shutdown 时机同步执行,行为与现状等价,无回归。
|
|
||||||
- 不用 `wp_schedule_single_event`:WP-Cron 粒度不可控(禁用循环回执的站点要等系统 cron,积分勋章到账会延迟数分钟),shutdown 方案在所有环境都能即时结算。
|
|
||||||
- `aksrt_user_followed` 仅 Follow.php:50 一处触发,4 个订阅者全部按传入 ID 工作、不依赖请求上下文,延迟执行安全。
|
|
||||||
- 效果:关注接口响应路径只剩 WP REST 启动 + 1 次 SELECT + 1 次 INSERT + 1 次 COUNT(全部命中索引),勋章评估从 3 遍降为 1 遍且不再阻塞响应。
|
|
||||||
|
|
||||||
## 不在本次范围
|
|
||||||
|
|
||||||
前端乐观更新(点击瞬间翻转按钮、失败回滚)——上轮标注为可选项,用户未明确要求;异步化后反馈延迟已大幅下降,且与全站点赞/收藏的交互模式保持一致。需要可后续单独做。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `php -l` 检查 3 个改动文件。
|
|
||||||
- 浏览器实测:关注点击后按钮/Toast 应明显变快(仅剩接口往返);稍后刷新可见积分/勋章/等级照常到账(结算仍在同一请求生命周期内完成,只是后置)。
|
|
||||||
- 取消关注路径不受影响(本就无钩子)。
|
|
||||||
|
|
||||||
## 提交
|
|
||||||
|
|
||||||
单条提交:`perf(user): 关注结算异步化并去除冗余勋章评估`。
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 修复前台文章卡片无刷新加载出现空位的问题
|
|
||||||
|
|
||||||
## 根因
|
|
||||||
|
|
||||||
规范分页控件(`aksrt_render_pagination()` 输出的 `.ajax-pag[data-aksrt-pagination]`,内含 `.next-page.ajax-next`)携带了遗留引擎的触发类 `ajax-next`。全站加载的 `main.min.js` 在 body 上委托监听 `.ajax-next` 点击(另有 IAS 无限滚动路径用 jQuery 模拟点击 `.ajax-next/.ias-btn`),导致每次"加载更多"被两套引擎同时处理:
|
|
||||||
|
|
||||||
1. **规范引擎**(pagination.js):POST `aksrt_load_more_posts`,把卡片正确插入 `.ajax-pag` 之前;
|
|
||||||
2. **遗留引擎**(main.min.js `post_ajax()`):抓取整页 `?paged=2` HTML,**先删除 `.ajax-pag`**,把占位加载器、重复的一批卡片和一个新的**块级** `.ajax-pag` div 插到最后一张卡之后。
|
|
||||||
|
|
||||||
卡片网格是 inline-block 布局(`width: calc(33.333% - 16px)`),块级分页 div / 重复批次插入流中会强制换行 → 网格中出现空位(即"隔出一个卡片位置"),且第 2 页内容被加载两次。评论分页(`inc/helpers/template-tags/search-meta.php:533`)已用 `order-ajax-next` + `ias-btn` 避开遗留引擎,文章分页是唯一例外。
|
|
||||||
|
|
||||||
另经确认:生产环境实际生效的懒加载运行时是 main.min.js 内置版(`assets/js/lazysizes.js` 无任何 enqueue),其 IAS 自动加载走 jQuery 模拟点击,只到得了遗留引擎——所以无限滚动模式目前完全由遗留引擎执行。若只摘掉 `ajax-next` 类而不接管 IAS 触发,无限滚动会失效,因此两处改动必须配套。
|
|
||||||
|
|
||||||
## 修改内容(4 个文件)
|
|
||||||
|
|
||||||
### 1. `inc/helpers/template-tags/pagination-permissions.php:123`
|
|
||||||
`.next-page` 包装类的 `ajax-next` → `order-ajax-next ias-btn`:
|
|
||||||
- 移除遗留引擎的点击触发 token(手动点击与 IAS 模拟点击双双失效于遗留引擎);
|
|
||||||
- `order-ajax-next` 复用现有按钮样式 `.theme-pagination .order-ajax-next a`(main.css:9659+),**零 CSS 改动**;
|
|
||||||
- `ias-btn` 保持 main.min.js 的 IAS 匹配器仍能找到元素(其后续 jQuery 点击成为无害空操作),且 pagination.js 的 clickTarget 选择器 `.next-page.ajax-next, .ias-btn` 原生支持,**零 JS 选择器改动**;
|
|
||||||
- 与评论分页的既有模式完全一致。
|
|
||||||
|
|
||||||
### 2. `assets/js/core/pagination.js`
|
|
||||||
在点击绑定块后新增容器级 `lazybeforeunveil` 监听(仅当服务端输出了 `lazyload` 契约类时挂载):unveil 事件由 main.min.js 内置 lazySizes 原生派发(可冒泡),监听到后设置 `lazyAuto` 并原生 `click()`,把无限滚动自动加载**收归规范引擎**的既有点击管线(复用 loading 守卫、iasMax 上限、按钮状态机):
|
|
||||||
```js
|
|
||||||
if (container.classList.contains("lazyload") || clickTarget.classList.contains("lazyload")) {
|
|
||||||
container.addEventListener("lazybeforeunveil", function (e) {
|
|
||||||
if (e.target !== container && e.target !== clickTarget) return;
|
|
||||||
clickTarget.dataset.lazyAuto = "1";
|
|
||||||
clickTarget.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
附带修复:评论分页的 IAS 自动加载此前同样到不了规范引擎(jQuery 模拟点击不可见),此监听一并接管。
|
|
||||||
|
|
||||||
### 3. `search.php:112-118` — 消除双层包裹
|
|
||||||
去掉外层冗余 `<div class="posts-row mtn6">`,把 `mtn6` 直接传入 `aksrt_render_post_list(['wrapper_class' => 'mtn6'])`。这样规范引擎的 `previousElementSibling` 目标解析落在真正的卡片网格上,追加的卡片与首页批在同一 inline-block 网格内连续排布(此前新卡片会追加为内层网格块的兄弟节点,卡片模式下在分页边界处出现空位)。ajax-list.js(搜索 tab 切换的实际引擎)用 `querySelectorAll('.ajax-item')` 提取、其替换流程本身就会把卡片拍平到容器层,结构兼容。
|
|
||||||
|
|
||||||
### 4. `tests/widget-system/post-visual-contract.test.cjs:238`
|
|
||||||
- 断言 `/class="next-page ajax-next/` → `/class="next-page order-ajax-next ias-btn/`;
|
|
||||||
- 新增 `assert.doesNotMatch(php, /next-page ajax-next/)` 锁定规范控件不再携带遗留触发类;
|
|
||||||
- 新增 `assert.match(js, /lazybeforeunveil/)` 锁定 IAS 接管。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `php -l inc/helpers/template-tags/pagination-permissions.php` 和 `php -l search.php`
|
|
||||||
- `node --test tests/widget-system/post-visual-contract.test.cjs`
|
|
||||||
- 浏览器实测(桌面 3 列卡片网格):首页点"加载更多"只发一个 `admin-ajax` 请求、无重复批次、无网格空位、`.ajax-pag` 始终位于网格下方;无限滚动模式每次自动加载一页并在 iasMax 后转手动按钮;搜索页卡片模式追加后与首批网格连续。
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 评论区硬切换:1:1 复刻 Zibll(不含表情包/快捷回复)
|
|
||||||
|
|
||||||
## 现状与结论
|
|
||||||
- AKSRT 评论区的 PHP 渲染(`inc/helpers/comments/item.php`、`form-parts.php`、`aksrt-comments.php`)与 JS(`assets/js/core/comments.js` 等走 `aksrt/v1` REST)已是自有代码,DOM 与 Zibll 基本一致(`li#comment-{id} > ul.list-inline > li.comt-main`、`.comment-header/.comment-content/.comt-meta`、`#commentform/.comt-title/.comt-box/.comt-ctrl` 均已对齐)。
|
|
||||||
- **样式仍由 vendored `assets/css/main.min.css` 提供**(拼进全站 `build/public.css`;`comments/shell.css` 头部明文"以 vendor 正版样式为准、禁止复制")——这是本次硬切换的主体:样式归属权切到自有文件,vendor 副本剥离。
|
|
||||||
- 快捷回复链路完整存在(schema/PHP/JS/CSS),需全链路删除;表情包已被此前的 commit 删除并有契约测试锁定,保持。
|
|
||||||
- 传输层不动:提交/点赞/编辑/删除/置顶仍走 AKSRT REST(含权限回调),不改回 Zibll 的 admin-ajax 协议;UI 与 DOM 对齐 Zibll。
|
|
||||||
|
|
||||||
## 1. CSS 硬切换(主体)
|
|
||||||
- 以参考主题 `C:\Users\Xing\Desktop\zibll\css\main.css` 为准移植评论区规则(与 AKSRT vendored 副本 diff 后以参考版为准):
|
|
||||||
- 新建 `assets/css/frontend/comments/layout.css`:桌面主段(AKSRT main.css 约 9273–9510 对应段:`#comments/#postcomments/#respond/#comment/.commentlist/.comt-*/.comment-*/.comment-signarea/.children/.comment-footer 缩进` 等)+ 散点规则(`.action-comment-like.action-animation`、`.comment-content .box-img`、`.comment-content a`、`.comment-content pre`)。
|
|
||||||
- 填充 `assets/css/frontend/comments/mobile-item.css`(现为空壳占位):移动端 `@media` 内评论规则(`.mobile-fixed/.virtual-input/头像缩进/分页` 等,main.css 约 12208 起)。
|
|
||||||
- `shell.css` 重写文件头教条(不再"vendor 提供"),保留 identity chip/medal 补充规则。
|
|
||||||
- **剔除**:`.dropdown-smilie/.smilie-icon/.dropdown-quick-often` 与 `img:not(.smilie-icon)` 类选择器。
|
|
||||||
- 从 vendored `assets/css/main.css`/`main.min.css` 剥离同批评论区域专属规则(死代码清理、消除双路)。保留:`.comment-widget-item/.comment-mini-lists`(侧栏小部件在非 singular 页使用)及共享选择器(`.pagenav/.theme-pagination/.line-form/.author-box/.avatar-img` 等别处仍用)。
|
|
||||||
- 先校验 `main.min.css ≙ minify(main.css)`(esbuild dry-run diff,防漂移),剥离后重新生成 `main.min.css`,再跑 `node tools/build-public-assets.mjs` 重出 bundle。
|
|
||||||
|
|
||||||
## 2. Markup 1:1 对齐(PHP 小增量)
|
|
||||||
- `item.php`:点赞按钮对齐 Zibll `zib_get_comment_like`(SVG 图标 + `<text></text><count>N</count>`,保留 AKSRT `data-comment-action` 接线);置顶/批准/菜单/注册图标由 FA 换成 Zibll SVG——新增小助手(如 `inc/helpers/comments/icons.php`)移植 `zib_get_svg` 所需 glyph(like/topping/approve/menu_2/signup);时间/回复链接 tooltip 属性对齐。
|
|
||||||
- `aksrt-comments.php` + `form-parts.php`:
|
|
||||||
- `!comments_open()` 时整体不渲染(Zibll 行为;当前会渲染标题+列表)。
|
|
||||||
- 工具栏渲染 `code→image→link`,补齐缺失的"链接"按钮(新设置 `comment_link`,默认关——Zibll 默认值即 false)。
|
|
||||||
- 游客 dropup 补"博客地址"字段(新设置 `comment_user_url_s`,默认关,同 Zibll)。
|
|
||||||
- `comt-title` 登录态昵称旁渲染 AKSRT 身份徽章(对应 Zibll VIP 图标位)。
|
|
||||||
|
|
||||||
## 3. 快捷回复全链路删除 + 死代码清理
|
|
||||||
- PHP:`form-parts.php` quick case 与工具栏分支;`aksrt-comments.php` context 的 `quick_s/quick_phrases`;`PostDisplay.php` 的 `quick_phrases` 字段与 `parse_comment_quick_phrases()`;`SchemaPostList.php` 镜像字段。
|
|
||||||
- Vue schema:`assets/admin/src/schema/sections/post-list/comment.js` 删 `comment_quick_s/comment_quick_often`。
|
|
||||||
- JS:`comment-tools.js` 快捷短语插入逻辑;`ScriptsPart1c.php` 删 `aksrtData.commentQuickPhrases`。
|
|
||||||
- 删除死文件:`assets/js/runtime/comment.js(+.min)`、`input-expand.js(+.min)`(已确认无代码引用)。
|
|
||||||
- `aksrt-runtime.js` 的 vendor comment 模块 stub 与委托解绑**保留**(vendor `main.min.js` 仍在全国 bundle,防回潮)。
|
|
||||||
|
|
||||||
## 4. 设置 schema(前后端镜像)
|
|
||||||
- 新增 `comment_link`、`comment_user_url_s` 开关(默认 false),镜像到 `SchemaPostList.php` 与 `PostDisplay::get_comment_display_settings()`;`npm run build` 重出 admin SPA。
|
|
||||||
|
|
||||||
## 5. 测试
|
|
||||||
- 更新 `comment-smilies-removal-contract.test.cjs`(文件清单移除已删 runtime 存档)。
|
|
||||||
- 新增 `tests/widget-system/comment-hard-cut-contract.test.cjs`:自有 comments CSS 含关键选择器清单;vendor main.css/min 不再含评论区选择器、仍含 `.comment-widget-item`;全库无快捷回复 token(`comment_quick_s/quick-reply-item/dropdown-quick-often/commentQuickPhrases/quick_phrases` 等);死文件不存在;markup 契约(`<count>`/SVG、工具栏 code/image/link 且无 quick/smilie)。
|
|
||||||
|
|
||||||
## 6. 构建与验证(不做视觉验收)
|
|
||||||
- `php -l` 全部改动 PHP;`node --test` 相关契约测试;`node tools/build-public-assets.mjs`;`npm run build`(admin)。
|
|
||||||
- 不启用 Docker 视觉对比环境(按用户要求)。
|
|
||||||
|
|
||||||
## 风险与对策
|
|
||||||
- vendor main.css 手术:先 min-sync 校验;剥离仅限评论专属选择器,逐个 grep AKSRT 模板确认无跨区使用;剥离后选择器清单复核。
|
|
||||||
- 评论区规则的 CSS 变量(`var(--…)`)与 bootstrap 类来自 bundle 中更早加载的 tokens.css/bootstrap,`aksrt-comments` 在其后加载,无影响。
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 公开用户主页去参数化 + 全链路无刷新加载
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
`/author/demo/` 的 URL 永远干净(无 `?tab=`、`follow_sub`、`follow_page`、`posts_page` 等),Tab 切换、粉丝/关注子切换、各面板分页全部走 REST AJAX 无刷新加载。单路径实现,不写双路/兼容/迁移代码。
|
|
||||||
|
|
||||||
现状:主 Tab 已是 DOM 切换,但 `author.js` 会把 `?tab=` 写进 URL;follow 子切换和 comments/follow 分页是带参整页链接;posts/favorites 分页走 `aksrt_render_pagination()`(numbered 模式产出 `?posts_page=N` 链接;load_more/infinite 走 admin-ajax)。
|
|
||||||
|
|
||||||
## 服务端
|
|
||||||
|
|
||||||
### 1. 上下文构建 — `inc/helpers/aksrt-user-home.php`
|
|
||||||
- `aksrt_get_user_home_page_context()` 删除全部 `$_GET` 读取(第 104、134-136 行):`active_tab` 恒等于 `default_tab`,三个 page 恒为 1(保留键,供 REST 覆写);新增 `follow_page => 1`、`follow_sub => 'followers'` 键。
|
|
||||||
- 新增 `aksrt_render_user_home_pagination(int $total, int $current)`:输出 `<nav class="profile-pagination uh-pagination">` + `<button type="button" class="page-numbers" data-uh-page="N">`(当前页带 `current` 类,含 `‹ ›` 前后页),复用现有 `.profile-pagination` 样式(lists.css:331+)。
|
|
||||||
- 新增 `aksrt_capture_user_home_panel(string $tab, array $ctx): string`:`locate_template('templates/parts/user-home/panel-{$tab}.php')` + ob_start 捕获(仿 `aksrt_capture_user_center_panel`,aksrt-user-center.php:199)。
|
|
||||||
|
|
||||||
### 2. 面板模板重构 — `templates/parts/user-home/`
|
|
||||||
- 删除 `panels-core.php`,拆为按面板 body 模板:`panel-posts.php`、`panel-comments.php`、`panel-favorites.php`、`panel-credits.php`;`panel-follow.php` 改为纯 body(去掉外层 wrapper,第 21-23 行 `$_GET` 读取改为从 `$args` 取 `$follow_page`/`$follow_sub`)。
|
|
||||||
- `page-user-home.php`:dashboard 根节点加 `data-user-id="<?php echo (int) $user_id; ?>"`;循环 `$enabled_tabs`,对存在 body 模板的 tab 输出 `<div id="panel-{tab}" class="dashboard-panel…">` wrapper + `get_template_part('templates/parts/user-home/panel', $tab, $ctx)`。
|
|
||||||
- 各 body 分页统一改用 `aksrt_render_user_home_pagination()`:
|
|
||||||
- posts/favorites 不再用 `aksrt_render_pagination()`(本页退出全站三模式分页体系);
|
|
||||||
- comments 不再用 `aksrt_render_profile_pagination()`(该函数保留,用户中心仍在用)。
|
|
||||||
- `panel-follow.php` 子切换 `<a href="?tab=follow&…">`(第 40、47 行)→ `<button type="button" data-uh-sub="followers|following">`;`paginate_links`(第 63-69 行)→ 新分页渲染器。
|
|
||||||
|
|
||||||
### 3. REST 端点 — `src/User/Home.php`(仿 `src/User/Center.php:34-110`)
|
|
||||||
- `init()` 挂 `rest_api_init`;路由 `GET aksrt/v1/user-home/panel/(?P<user_id>\d+)/(?P<tab>[a-z0-9_-]+)`,query 参数 `page`(int ≥1)、`sub`(followers|following),`permission_callback => '__return_true'`(公开数据)。
|
|
||||||
- `rest_panel()`:校验用户存在 → `aksrt_get_user_home_page_context()` 重建上下文 → 用请求参数覆写 `active_tab`、对应 page、`follow_sub` → tab 不在 `enabled_tabs`(如 credits 非本人)返回 404 → 捕获 body → 返回 `{success:true, data:{tab, html}}`。
|
|
||||||
|
|
||||||
## 前端 — `assets/js/author.js`
|
|
||||||
|
|
||||||
- 删除 `updateTabUrl()`(第 15-24 行)与 `resolveInitialTab()`(第 70-73 行):不再读写任何 URL 参数,初始态以服务端渲染的 `is-active`/`data-default-tab` 为准。
|
|
||||||
- 新增 `loadPanelBody(tab, page, sub)`:`fetch(aksrtData.apiUrl + '/user-home/panel/{data-user-id}/{tab}?page=N(&sub=X)', {credentials:'same-origin', headers:{'X-WP-Nonce': aksrtData.restNonce}})` → `panel.innerHTML = data.html` → `AksrtFrontend.rehydrate(panel, 'ajax')`;per-panel 请求去重防竞态(仿 user-center.js `_aksrtPanelRequest`);`is-loading` 态 + 失败 notyf 提示。
|
|
||||||
- dashboard 根上事件委托(覆盖 AJAX 注入的新按钮):
|
|
||||||
- `[data-uh-sub]` → 加载 follow 面板 `sub=X, page=1`;
|
|
||||||
- `[data-uh-page]` → `btn.closest('.dashboard-panel')` 得 tab,`panel.dataset.uhSub`(默认 followers)得子项,加载对应页。
|
|
||||||
- URL 永不变更(无 pushState/replaceState)。`follow.js` 是 document 级委托,注入的 minicard 关注按钮自动可用,无需改动。
|
|
||||||
|
|
||||||
## 附带清理与 CSS
|
|
||||||
|
|
||||||
- `templates/parts/submit/editor-main.php:106-108`:「查看草稿」现指向公开主页 + `?tab=post&post_status=draft`(公开主页从未支持该参数,本就错位)。改为 `add_query_arg('post_status', 'draft', aksrt_get_user_center_tab_url('posts'))`(用户中心支持该过滤,posts-list.php:14-16)。
|
|
||||||
- `assets/css/routes/user-home.css`:`button.page-numbers` 复位 + hover 态(现 hover 选择器只覆盖 `a.page-numbers`);`.uh-follow__subtab` 改 button 后的复位;`.dashboard-panel.is-loading` 半透明过渡。
|
|
||||||
|
|
||||||
## 测试与验证
|
|
||||||
|
|
||||||
- 更新 `tests/widget-system/post-visual-contract.test.cjs`:移除 `panels-core.php`/`page_var` 断言(第 39、205、222-224、328-330 行),改为断言:各 `panel-*.php` body 模板存在且调用 `aksrt_render_user_home_pagination`;user-home 模板无 `$_GET`/`add_query_arg`;author.js 无 `searchParams`(防回归契约)。
|
|
||||||
- `php -l` 全部改动 PHP 文件;`node --test tests/widget-system/post-visual-contract.test.cjs`。
|
|
||||||
|
|
||||||
## 明确的行为变化(单路径的直接结果)
|
|
||||||
|
|
||||||
- 公开主页 posts/favorites 分页不再跟随后台「文章分页模式」(numbered/加载更多/无限滚动)设置,统一为本页 AJAX 数字分页。
|
|
||||||
- 刷新/分享/回退永远回到默认 Tab 第 1 页(URL 无状态,需求本身决定)。
|
|
||||||
- 旧外链 `?tab=follow` 不解析不重定向,按默认 Tab 加载(无兼容代码)。
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 复刻 Zibll 粉丝/关注用户列表(公开主页 follow TAB)
|
|
||||||
|
|
||||||
## 问题根源
|
|
||||||
|
|
||||||
`templates/parts/user-home/panel-follow.php:46-65` 手写的列表 markup(`.uh-follow__list/item/avatar/name`)在全仓库没有任何 CSS 定义,头像+用户名以内联文本堆叠显示。列表项也只有头像和用户名,没有 Zibll 的卡片结构。
|
|
||||||
|
|
||||||
关键事实:AKSRT 已 vendored Zibll 的 `main.css`(`.author-minicard` 卡片规则、工具类齐全),且 `inc/helpers/aksrt-graphic-card.php:215` 已有 `aksrt_author_minicard()` —— Zibll `zib_author_card()` 的移植版(42px 懒加载头像、用户名+勋章/认证/等级图标、逐项关注按钮、个性签名一行省略、默认文案"这家伙很懒,什么都没有写…"),搜索页在用。**修复 = 粉丝/关注列表改走这条既有链路**,不新写卡片样式。
|
|
||||||
|
|
||||||
## 改动清单
|
|
||||||
|
|
||||||
### 1. `templates/parts/user-home/panel-follow.php`(核心)
|
|
||||||
- 列表循环改为 `aksrt_author_minicard((int) $row['user_id'])`,删除手写的 `.uh-follow__item/avatar/name` markup(渲染链路与搜索页用户列表完全一致)。
|
|
||||||
- 子切换复刻 Zibll 结构(`zib-author.php:407-410`):居中文字链接「粉丝 N | 关注 N」(`list-inline splitters` + 激活 `focus-color` / 非激活 `muted-color`),**顺序改为粉丝在前、默认子列表改为粉丝**(Zibll 逻辑:TAB 名"粉丝"、默认显示粉丝列表,`zib_get_follow_user_list` 默认 `type='followed'`)。链接仍走现有 `?follow_sub=&follow_page=` 路由。
|
|
||||||
- 空状态图标换心形 SVG(见第 6 条),继续用 `aksrt_render_profile_empty` 组件(对齐 Zibll null-love 爱心语义)。
|
|
||||||
- 分页保持 `paginate_links` 服务端翻页:Zibll 的 AJAX"加载更多"框架(post_ajax/ajax-next)未移植进 AKSRT(main.js 仅 27 行),且公开主页所有 TAB 统一为服务端渲染 + URL 参数翻页,不为此引入独立 AJAX 体系。
|
|
||||||
|
|
||||||
### 2. `inc/helpers/aksrt-graphic-card.php` — `aksrt_author_minicard()`
|
|
||||||
- 关注按钮改为**服务端渲染真实状态**(复刻 Zibll 逻辑):已登录且 `\AKSRT\User\Follow::is_following(current, target)` 为真时输出 `is-following` class +「已关注」文案。消除每次进页"全部显示关注→异步闪变"的问题。搜索页同样受益。
|
|
||||||
|
|
||||||
### 3. `templates/parts/user/author-hero.php`
|
|
||||||
- hero 关注按钮(L151-163)同样服务端渲染真实初始状态(`is-following` +「已关注」),与卡片按钮同一逻辑。
|
|
||||||
|
|
||||||
### 4. `assets/js/follow.js`
|
|
||||||
- 删除逐按钮 `/follow/status` 轮询(L54-69):服务端已输出真值,不留双路。保留点击 toggle、`updateFollowButton` 状态切换、用户中心取关移除行、计数更新。
|
|
||||||
|
|
||||||
### 5. `assets/css/routes/user-home.css` — follow 区样式替换
|
|
||||||
- `.uh-follow__list { text-align: center; }`:对齐 Zibll pane 的 `text-center`(卡片为 inline-block,自动 3 列居中换行,移动端单列规则 main.css 已有)。
|
|
||||||
- 子切换:删除旧 pill 按钮样式块(L486-519),替换为 Zibll 风格的少量布局规则(居中、间距),颜色/激活态复用 vendored 工具类。
|
|
||||||
- 同步删除不再被引用的 `__subtab-count` 等旧规则。
|
|
||||||
|
|
||||||
### 6. `inc/helpers/aksrt-user-home.php`
|
|
||||||
- context 数组新增 `svg_empty_follow`(心形 stroke SVG,风格同现有 `svg_empty_post` 等)。
|
|
||||||
|
|
||||||
## 不做的事(明确排除)
|
|
||||||
- 不引入 AJAX"加载更多"(无框架基础,破坏公开主页统一服务端分页架构)。
|
|
||||||
- 不动数据层 `src/User/Follow.php`(`get_user_list` 已提供所需 user_id;徽章/签名由 minicard 自行取)。
|
|
||||||
- 不写任何双路、兼容、迁移代码:旧的手写列表 markup 和 pill 子切换样式直接替换删除。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
- `php -l` 检查全部改动的 PHP 文件。
|
|
||||||
- `node --test tests/widget-system/*.test.cjs` 确认不破坏既有契约(无该面板专属测试)。
|
|
||||||
- 桌面 + 移动宽度下目视核对:卡片 3 列/单列、关注按钮初始状态、子切换、空状态、分页。
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# assets/vendor 全量迁移 + 全仓去 zibll 化
|
|
||||||
|
|
||||||
## 目标布局(类型归位,`git mv` 保留历史)
|
|
||||||
|
|
||||||
| 源 | 目标 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `assets/vendor/fontawesome5/` | `assets/fontawesome5/` | css/ + webfonts/ 原结构,`../webfonts` 相对引用不变 |
|
|
||||||
| `assets/vendor/zibll/css/`(22 文件) | `assets/css/` 顶层 | bootstrap/animate/swiper/main 等,无命名冲突;`main(.min).css` 的 `url(../img/playing.svg)` 迁后恰好仍指向 `assets/img`,无需改 |
|
|
||||||
| `assets/vendor/zibll/js/**` | `assets/js/runtime/**` | **必须子目录**:assets/js 已有 14 个同名自有文件(author/sign-register/page-navs…)和一个 enlighter/ 目录 |
|
|
||||||
| `assets/vendor/zibll/img/captcha/` | `assets/img/captcha/` | slidercaptcha.js 运行时按 `_win.uri + '/img/captcha/'` 拼接,新 uri=assets 根后自然命中 |
|
|
||||||
| `assets/vendor/zibll/img/{medal,smilies}` | 与 `assets/img/` 哈希比对 | 相同跳过、缺失补齐;**内容不同时以当前实际渲染源为准**(medal/smilies 保现有,user_t.jpg/slider-bg.jpg 以 vendor 版覆盖,因 PHP 默认值当前渲染的是 vendor 版) |
|
|
||||||
| `assets/vendor/zibll/img/playing.svg` 等散图 | `assets/img/` | |
|
|
||||||
| `assets/vendor/zibll/fonts/`(孤儿 2 文件) | `assets/fonts/` | 按"全部迁移"保留 |
|
|
||||||
| `js/libs/{DPlayer,dash.all,flv,hls,vanilla-tilt}.min.js` | **不迁移**(智能去重) | 先哈希验证与 `assets/widgets/third-party/` 字节一致(探索已确认大小一致);loader.js paths 改指 `'../widgets/third-party/DPlayer.min'` 等(相对 baseUrl);若任一哈希不符则该文件照常迁入 `runtime/libs/` |
|
|
||||||
|
|
||||||
完成后删除 `assets/vendor/`。
|
|
||||||
|
|
||||||
## 运行时切换(核心链路 `_win.uri`)
|
|
||||||
|
|
||||||
PHP 将 `'uri'` 从 `…/assets/vendor/zibll` 改为 `…/assets`,loader(改造版 RequireJS)以 `baseUrl = _win.uri + '/js'` 解析懒加载模块:
|
|
||||||
|
|
||||||
1. `assets/js/runtime/loader.js`:`baseUrl` 改为 `_win.uri + '/js/runtime'`(paths 表 `'libs/swiper.min'`、`'enlighter/enlighterjs.min'` 等相对值全部随之命中,无需逐条改,除 5 个去重库改 `../widgets/third-party/`)
|
|
||||||
2. `main.js` + `main.min.js`:enlighter CSS 注入 `_win.uri + '/js/enlighter/…'` → `'/js/runtime/enlighter/…'`(swiper CSS 注入 `/css/swiper.min.css` 迁后自然命中,不改)
|
|
||||||
3. 已核实的死引用(`/css/dplayer.min.css` 404、`/action/action.php`、`/yiyan/…`、paths 中 `shop—main`)保持原状不修不扩
|
|
||||||
|
|
||||||
## PHP 引用切换(7 个文件)
|
|
||||||
|
|
||||||
- `src/Core/ScriptsPart1b.php:16-17,23,38-41`、`src/Core/ScriptsPart1c.php:35-42,53`:新变量 `$runtime_uri = $base_uri . '/assets'`、`$fa5_uri = $base_uri . '/assets/fontawesome5'`;jquery/bootstrap/loader/main 路径改 `assets/js/runtime/…`
|
|
||||||
- `inc/core-hooks/part-3.php:52`、`src/Admin/Admin.php:116`:FA5 新路径
|
|
||||||
- `src/Admin/PageConfigMetaBox.php:275,277`、`src/Settings/DisplayPageGroups.php:96,98`、`src/Admin/Schema/SchemaPageDisplay.php:77,123,138,163,190`:`assets/vendor/zibll/img/` → `assets/img/`
|
|
||||||
|
|
||||||
## 全仓去 zibll 清扫(用户已确认含功能性标识符)
|
|
||||||
|
|
||||||
先跑全仓盘点(排除 .git、composer vendor、node_modules、tests/visual、assets/admin/build):`zibll/Zibll、zib__、zib_ajax、zibllblock、wp-block-zibllblock、.zib-、_zb、Qinver、zibll.com、子比`,产出替换映射并核对目标名无既有冲突(如 `aksrt_ajax`)。统一替换规则:
|
|
||||||
|
|
||||||
- **品牌头注释**:`@Author: Qinver / @Url: zibll.com / @Project: Zibll子比主题` → `@package AKSRT`;swiper 内 `//zibll : 修改内容` 等注释中性化;`main.js:4866` 的 `console.log(' Zibll Theme …')` 及 min 等价物删除
|
|
||||||
- **功能性标识符重命名**(生产端+消费端原子同步,含 min 文件 sed,改后 diff 抽查只允许替换点变化):
|
|
||||||
- `zibllblock/*` → `aksrtblock/*`;`zibll_block_cat/var` → `aksrt_block_cat/var`;`.wp-block-zibllblock-*` → `.wp-block-aksrtblock-*`(⚠️ 数据库已发布内容中的旧块名将无法渲染,用户已接受且明确不写兼容层)
|
|
||||||
- `'zibll' + type` localStorage 键 → `'aksrt' + type`(游客点赞记录重置,已接受)
|
|
||||||
- `zib__()` i18n 函数 → `aksrt__()`(先定位定义处,全量替换所有调用,含各 .min.js)
|
|
||||||
- `zib_ajax` 等运行时函数按盘点结果同步改名
|
|
||||||
- **AKSRT 自有资产**:`assets/css/frontend/zibll-utilities.css → legacy-utilities.css`、`zibll-compat-vars.css → compat-vars.css`、`footer/zibll-shell.css → footer/shell.css`(含 enqueue_module 调用、handle 派生、测试引用);`.zib-widget`(19处)/`.zib-slider` → `.aksrt-widget`/`.aksrt-slider`(CSS + PHP 输出标记 + vendor js 选择器同步)
|
|
||||||
- **PHP 变量/注释**:`$zibll_uri` 等改名;所触及文件注释中 Zibll 措辞改写为"legacy runtime"
|
|
||||||
- **admin SPA**:Vue 源内用户可见 zibll 文案改写后 `cd assets/admin && npm run build`
|
|
||||||
- **例外保留**:`tests/visual/`(docker-compose.zibll.yml、zibll-Caddyfile、baseline)指向外部真实 Zibll 对照主题,属产品名引用,不改
|
|
||||||
|
|
||||||
## 测试同步
|
|
||||||
|
|
||||||
- `tests/navigation/zibll-navigation-contract.test.cjs`(本地未跟踪):所有 vendor 路径 → 新路径;walk 根改 `assets/js/runtime`(js)与 `assets/css` 顶层(css);新增断言 `assets/vendor` 目录不存在;文件更名 `legacy-navigation-contract.test.cjs`
|
|
||||||
- `tests/widget-system/{post-visual,toast,page-display,lazyload}-contract.test.cjs`:loader/main 读取路径更新;toast 的 `$zibll_loader_version`/`$zibll_runtime_version` 正则同步新变量名;lazyload `sourceFiles` skip 列表把 `'vendor'` 换成/加上 `'runtime'` 保持语义
|
|
||||||
- 其他 widget-system 测试中引用 zibll 路径/字样的断言同步更新
|
|
||||||
|
|
||||||
## 验证与收尾
|
|
||||||
|
|
||||||
1. `php -l` 逐个检查所有改动 PHP
|
|
||||||
2. `node --test tests/widget-system/post-visual-contract.test.cjs` 等全部受影响 contract 测试 + 更名后的 navigation 测试
|
|
||||||
3. grep 审计:全仓(按上述排除项)`zibll|Qinver|子比|zib__` 零残留;`assets/vendor` 零引用
|
|
||||||
4. 手工 QA 清单:首页/文章页/评论区、FA 图标、滑块验证码图、勋章、enlighter 高亮、DPlayer 视频播放
|
|
||||||
5. 不主动提交;建议单原子 commit:`refactor(assets): vendor 资产迁入标准目录并全仓去除 zibll 标识`(移动+切换+清扫+测试必须同 commit 才能保持可用)
|
|
||||||
|
|
||||||
## 风险提示(用户已确认接受)
|
|
||||||
|
|
||||||
- 数据库中已存 `zibllblock/*` 块的旧文章渲染损坏(无兼容层)
|
|
||||||
- 游客 localStorage 点赞/收藏记录重置
|
|
||||||
- min 文件 sed 属精细操作,逐文件 diff 验证
|
|
||||||
@@ -12,12 +12,9 @@ AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题
|
|||||||
### 核心特性
|
### 核心特性
|
||||||
|
|
||||||
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
|
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
|
||||||
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出、按域重置、迁移状态与健康检查
|
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出
|
||||||
- **支付/下载主线** — 下载/付费内容中心、充值/VIP/分成/返佣/网关结构、10 分钟下载 token
|
|
||||||
- **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
|
- **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
|
||||||
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知中心
|
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知
|
||||||
- **运维工具** — 配置导出导入、dry-run 校验、备份、数据库与环境诊断
|
|
||||||
- **自定义页面模板**:About / Friends / Login / Profile / Register
|
|
||||||
- **响应式设计**
|
- **响应式设计**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -167,6 +164,17 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
|||||||
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
|
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
|
||||||
- `备份&导入`:导入导出、健康检查、迁移状态、备份与更新
|
- `备份&导入`:导入导出、健康检查、迁移状态、备份与更新
|
||||||
|
|
||||||
|
### 4. 推广、邀请与佣金(用户中心 → 收益)
|
||||||
|
|
||||||
|
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
|
||||||
|
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或 Zibll 兼容文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
|
||||||
|
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
|
||||||
|
|
||||||
|
### 5. 部署注意事项
|
||||||
|
|
||||||
|
- **佣金结算时效**:结算依赖小时级 wp-cron,并有收益页访问触发的懒结算兜底。高流量站点建议禁用 `WP_CRON_LOCK` 场景下的默认 WP-Cron,改用系统 crontab 定时请求 `wp-cron.php`,保证结算与过期任务准时执行。
|
||||||
|
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('aksrt_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 开发说明
|
## 开发说明
|
||||||
|
|||||||
@@ -5,11 +5,4 @@ export const extensionsApiMethods = {
|
|||||||
|
|
||||||
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
|
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
|
||||||
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
|
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
|
||||||
|
|
||||||
/* AI 辅助 */
|
|
||||||
getAiStatus() { return this.request('/ai/status') },
|
|
||||||
updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) },
|
|
||||||
generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) },
|
|
||||||
suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) },
|
|
||||||
suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) },
|
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,6 @@ export const globalApiMethods = {
|
|||||||
* 全局&功能 (Global & Functions)
|
* 全局&功能 (Global & Functions)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getMailSettings() { return this.request('/settings/mail') },
|
|
||||||
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
||||||
|
|
||||||
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
||||||
@@ -13,7 +11,6 @@ export const globalApiMethods = {
|
|||||||
|
|
||||||
getFloatingButtons() { return this.request('/settings/floating-buttons') },
|
getFloatingButtons() { return this.request('/settings/floating-buttons') },
|
||||||
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
|
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
|
||||||
|
|
||||||
getFriendLinks() { return this.request('/friend-links') },
|
getFriendLinks() { return this.request('/friend-links') },
|
||||||
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
|
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Icon catalog API — serves the universal icon picker (FA5 + sprite + custom).
|
||||||
|
*/
|
||||||
|
export const iconsApiMethods = {
|
||||||
|
getIconCatalog() {
|
||||||
|
return this.request('/icons/catalog')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,18 +2,18 @@ import { globalApiMethods } from './global.js'
|
|||||||
import { pageDisplayApiMethods } from './page-display.js'
|
import { pageDisplayApiMethods } from './page-display.js'
|
||||||
import { permissionsApiMethods } from './permissions.js'
|
import { permissionsApiMethods } from './permissions.js'
|
||||||
import { usersApiMethods } from './users.js'
|
import { usersApiMethods } from './users.js'
|
||||||
import { paymentApiMethods } from './payment.js'
|
|
||||||
import { extensionsApiMethods } from './extensions.js'
|
import { extensionsApiMethods } from './extensions.js'
|
||||||
import { toolsApiMethods } from './tools.js'
|
import { toolsApiMethods } from './tools.js'
|
||||||
import { schemaApiMethods } from './schema.js'
|
import { schemaApiMethods } from './schema.js'
|
||||||
|
import { iconsApiMethods } from './icons.js'
|
||||||
|
|
||||||
export const apiMethods = {
|
export const apiMethods = {
|
||||||
...globalApiMethods,
|
...globalApiMethods,
|
||||||
...pageDisplayApiMethods,
|
...pageDisplayApiMethods,
|
||||||
...permissionsApiMethods,
|
...permissionsApiMethods,
|
||||||
...usersApiMethods,
|
...usersApiMethods,
|
||||||
...paymentApiMethods,
|
|
||||||
...extensionsApiMethods,
|
...extensionsApiMethods,
|
||||||
...toolsApiMethods,
|
...toolsApiMethods,
|
||||||
...schemaApiMethods,
|
...schemaApiMethods,
|
||||||
|
...iconsApiMethods,
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const paymentApiMethods = {
|
|
||||||
/* ================================================================
|
|
||||||
* 支付&付费 (Payment)
|
|
||||||
* ================================================================ */
|
|
||||||
|
|
||||||
getPaymentOverview() { return this.request('/payment/overview') },
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,6 @@ export const permissionsApiMethods = {
|
|||||||
* 功能&权限 (Permissions)
|
* 功能&权限 (Permissions)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
|
|
||||||
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
|
|
||||||
|
|
||||||
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
|
|
||||||
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
|
|
||||||
|
|
||||||
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
|
|
||||||
|
|
||||||
getCapabilities() { return this.request('/permissions/capabilities') },
|
getCapabilities() { return this.request('/permissions/capabilities') },
|
||||||
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
|
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ export const usersApiMethods = {
|
|||||||
|
|
||||||
getAuthSettings() { return this.request('/settings/auth') },
|
getAuthSettings() { return this.request('/settings/auth') },
|
||||||
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
|
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
|
testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) },
|
||||||
|
|
||||||
getNotificationSettings() { return this.request('/settings/notifications') },
|
getNotificationSettings() { return this.request('/settings/notifications') },
|
||||||
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
|
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
@@ -17,14 +18,6 @@ export const usersApiMethods = {
|
|||||||
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
|
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
|
||||||
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
|
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
|
||||||
|
|
||||||
getIdentitySettings() { return this.request('/identity/settings') },
|
|
||||||
saveIdentitySettings(data) {
|
|
||||||
return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) })
|
|
||||||
},
|
|
||||||
testIdentitySettings(data = {}) {
|
|
||||||
return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) })
|
|
||||||
},
|
|
||||||
|
|
||||||
getCreditSettings() { return this.request('/assets/settings') },
|
getCreditSettings() { return this.request('/assets/settings') },
|
||||||
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
listCreditUsers(params = {}) {
|
listCreditUsers(params = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<n-form label-placement="left" label-width="110" class="aksrt-form float-common-fields">
|
||||||
|
<n-form-item label="PC端显示" :show-feedback="false">
|
||||||
|
<n-switch :value="!!modelValue.pc_s" @update:value="update('pc_s', $event)" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="移动端显示" :show-feedback="false">
|
||||||
|
<n-switch :value="!!modelValue.m_s" @update:value="update('m_s', $event)" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="颜色" :show-feedback="false">
|
||||||
|
<div class="float-common-fields__colors">
|
||||||
|
<div class="float-common-fields__color">
|
||||||
|
<span>自定义图标颜色</span>
|
||||||
|
<n-color-picker
|
||||||
|
:value="modelValue.color?.color || null"
|
||||||
|
:show-alpha="true"
|
||||||
|
:modes="['hex', 'rgb']"
|
||||||
|
clearable
|
||||||
|
@update:value="updateColor('color', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="float-common-fields__color">
|
||||||
|
<span>自定义背景颜色</span>
|
||||||
|
<n-color-picker
|
||||||
|
:value="modelValue.color?.bg || null"
|
||||||
|
:show-alpha="true"
|
||||||
|
:modes="['hex', 'rgb']"
|
||||||
|
clearable
|
||||||
|
@update:value="updateColor('bg', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<div class="float-common-fields__desc">自定义颜色请注意日间、夜间模式的适配</div>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
function update(key, value) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColor(key, value) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
color: { ...(props.modelValue.color || { color: '', bg: '' }), [key]: value || '' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-common-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__colors {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__color {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
padding-left: 110px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="float-btn-panel">
|
||||||
|
<div class="float-btn-panel__head" @click="expanded = !expanded">
|
||||||
|
<button type="button" class="float-btn-panel__handle" aria-label="拖拽排序" @click.stop>
|
||||||
|
<i class="fa fas fa-arrows-alt-v" />
|
||||||
|
</button>
|
||||||
|
<strong class="float-btn-panel__title">{{ meta.title }}</strong>
|
||||||
|
<template v-if="modelValue.type !== 'more'">
|
||||||
|
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
|
||||||
|
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
|
||||||
|
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
|
||||||
|
</template>
|
||||||
|
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
|
||||||
|
<span v-if="meta.warn" class="float-btn-panel__warn">
|
||||||
|
<i class="fa fas fa-info-circle" /> {{ meta.warn }}
|
||||||
|
</span>
|
||||||
|
<i class="fa fas float-btn-panel__arrow" :class="expanded ? 'fa-angle-up' : 'fa-angle-down'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="expanded" class="float-btn-panel__body">
|
||||||
|
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
|
||||||
|
|
||||||
|
<FloatMoreButtonsEditor
|
||||||
|
v-if="modelValue.type === 'more'"
|
||||||
|
:model-value="modelValue.buttons"
|
||||||
|
@update:model-value="patch({ buttons: $event })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<FloatBtnCommonFields :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" />
|
||||||
|
|
||||||
|
<n-form label-placement="left" label-width="110" class="aksrt-form float-btn-panel__extra">
|
||||||
|
<n-form-item v-if="modelValue.type === 'qrcode'" label="一句话简介">
|
||||||
|
<n-input :value="modelValue.desc" @update:value="patch({ desc: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'add'" label="按钮图标">
|
||||||
|
<FaIconPicker v-model="addIcon" placeholder="选择图标" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'service_qq'" label="QQ号码">
|
||||||
|
<n-input :value="modelValue.qq" placeholder="仅填写数字" @update:value="patch({ qq: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'service_wechat'" label="微信二维码">
|
||||||
|
<AksMediaField
|
||||||
|
:model-value="modelValue.wechat_img"
|
||||||
|
placeholder="选择二维码图片"
|
||||||
|
@update:model-value="patch({ wechat_img: $event })"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import AksMediaField from '@/components/form/AksMediaField.vue'
|
||||||
|
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||||
|
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
|
||||||
|
import { FLOAT_BTN_META } from './floating-defaults.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
|
||||||
|
|
||||||
|
const addIcon = computed({
|
||||||
|
get: () => props.modelValue.icon || '',
|
||||||
|
set: (value) => patch({ icon: value || '' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
function patch(partial) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...partial })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-btn-panel {
|
||||||
|
border: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--n-card-color, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__handle {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--n-text-color-3, #999);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--n-text-color-3, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__warn {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f0a020;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__body {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border-top: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__subtitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__extra {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3, #999);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div class="float-more-editor">
|
||||||
|
<n-collapse :default-expanded-names="[]" accordion>
|
||||||
|
<n-collapse-item
|
||||||
|
v-for="(btn, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
:name="index"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="float-more-editor__head">
|
||||||
|
<IconValue v-if="btn.icon" :value="btn.icon" />
|
||||||
|
<span>{{ btn.desc || `按钮 ${index + 1}` }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
:disabled="modelValue.length <= 1"
|
||||||
|
@click.stop="remove(index)"
|
||||||
|
>
|
||||||
|
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form label-placement="left" label-width="110" class="aksrt-form">
|
||||||
|
<n-form-item label="按钮简介">
|
||||||
|
<n-input :value="btn.desc" @update:value="patch(index, { desc: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="按钮图标">
|
||||||
|
<FaIconPicker :model-value="btn.icon" placeholder="选择图标" @update:model-value="patch(index, { icon: $event || '' })" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="!btn.hover" label="按钮链接">
|
||||||
|
<div class="float-more-editor__link">
|
||||||
|
<n-input
|
||||||
|
:value="btn.link.url"
|
||||||
|
placeholder="https://"
|
||||||
|
@update:value="patch(index, { link: { ...btn.link, url: $event } })"
|
||||||
|
/>
|
||||||
|
<n-checkbox
|
||||||
|
:checked="btn.link.target === '_blank'"
|
||||||
|
@update:checked="patch(index, { link: { ...btn.link, target: $event ? '_blank' : '' } })"
|
||||||
|
>
|
||||||
|
新窗口打开
|
||||||
|
</n-checkbox>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="悬浮内容">
|
||||||
|
<div class="float-more-editor__block">
|
||||||
|
<div class="float-more-editor__subtitle">鼠标移到此处时显示内容</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="btn.hover"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||||
|
@update:value="patch(index, { hover: $event })"
|
||||||
|
/>
|
||||||
|
<div class="float-more-editor__desc">设置此处后,按钮链接将失效<br>支持HTML代码,请注意代码规范及标签闭合</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="btn.hover" label="悬浮内容宽度">
|
||||||
|
<div class="float-more-editor__block">
|
||||||
|
<n-input-number
|
||||||
|
:value="btn.hover_width"
|
||||||
|
:min="50"
|
||||||
|
:max="500"
|
||||||
|
:step="10"
|
||||||
|
style="width: 180px;"
|
||||||
|
@update:value="patch(index, { hover_width: $event ?? 200 })"
|
||||||
|
>
|
||||||
|
<template #suffix>PX</template>
|
||||||
|
</n-input-number>
|
||||||
|
<div class="float-more-editor__desc">移动端宽度最大240px</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<FloatBtnCommonFields :model-value="btn" @update:model-value="replace(index, $event)" />
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
|
||||||
|
<div class="float-more-editor__footer">
|
||||||
|
<n-button size="small" type="primary" @click="add">
|
||||||
|
<template #icon><i class="fa fas fa-plus" /></template>
|
||||||
|
添加按钮
|
||||||
|
</n-button>
|
||||||
|
<span class="float-more-editor__desc">此处内容至少保留一个,如一个都无需显示请关闭PC端和移动端显示即可</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import IconValue from '@/components/form/IconValue.vue'
|
||||||
|
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||||
|
import { defaultMoreButton } from './floating-defaults.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
function replace(index, value) {
|
||||||
|
const next = props.modelValue.slice()
|
||||||
|
next.splice(index, 1, value)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(index, partial) {
|
||||||
|
replace(index, { ...props.modelValue[index], ...partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
emit('update:modelValue', [...props.modelValue, defaultMoreButton()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index) {
|
||||||
|
if (props.modelValue.length <= 1) return
|
||||||
|
const next = props.modelValue.slice()
|
||||||
|
next.splice(index, 1)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-more-editor__head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__subtitle,
|
||||||
|
.float-more-editor__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 右侧悬浮按钮:类型元数据与默认值(与 src/Settings/FloatButtons.php 保持一致)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const emptyColor = () => ({ color: '', bg: '' })
|
||||||
|
|
||||||
|
/** 类型元数据:面板标题、折叠标题、黄色提示。 */
|
||||||
|
export const FLOAT_BTN_META = {
|
||||||
|
back_top: { title: '返回顶部', panel: '返回顶部', warn: '此按钮只能放置在最顶部或者最底部!' },
|
||||||
|
qrcode: { title: '当前页面二维码', panel: '当前页面二维码' },
|
||||||
|
add: { title: 'Add发布内容', panel: '发布投稿', warn: '此按钮尽量放在上方,否则会显示遮挡' },
|
||||||
|
theme_mode: { title: '日夜主题切换', panel: '日夜主题切换' },
|
||||||
|
pay_vip: { title: 'VIP购买', panel: 'VIP购买' },
|
||||||
|
service_qq: { title: 'QQ客服', panel: 'QQ客服' },
|
||||||
|
service_wechat: { title: '微信客服', panel: '微信客服' },
|
||||||
|
more: { title: '更多按钮', panel: '添加更多自定义按钮' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeUri = (window.aksrtAdminData?.themeUri || '').replace(/\/$/, '')
|
||||||
|
|
||||||
|
/** 「更多按钮」出厂默认项(对齐参考主题 float_btn.more 默认值)。 */
|
||||||
|
export function giftMoreButton() {
|
||||||
|
return {
|
||||||
|
desc: '本站同款主题模板',
|
||||||
|
icon: 'zibsvg-gift-color',
|
||||||
|
link: { url: '', target: '' },
|
||||||
|
hover: `<a href="https://www.zibll.com/" target="_blank">
|
||||||
|
<div class="flex c-red">
|
||||||
|
<img class="flex0" alt="zibll子比主题" src="${themeUri}/assets/img/logo.png" height="30">
|
||||||
|
<div class="flex1 ml10">
|
||||||
|
<dt>本站同款主题模板</dt>
|
||||||
|
<div class="px12 mt10 muted-color">zibll子比主题是一款漂亮优雅的网站主题模板,功能强大,配置简单。</div>
|
||||||
|
<div class="but mt10 p2-10 c-blue btn-block px12">查看详情</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>`,
|
||||||
|
hover_width: 240,
|
||||||
|
pc_s: true,
|
||||||
|
m_s: true,
|
||||||
|
color: { color: '', bg: 'rgba(255, 111, 6, 0.2)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「添加按钮」新增项默认值(参考主题 group 字段默认值)。 */
|
||||||
|
export function defaultMoreButton() {
|
||||||
|
return {
|
||||||
|
desc: '',
|
||||||
|
icon: 'fa fas fa-heart',
|
||||||
|
link: { url: '', target: '' },
|
||||||
|
hover: '',
|
||||||
|
hover_width: 200,
|
||||||
|
pc_s: true,
|
||||||
|
m_s: true,
|
||||||
|
color: { color: '', bg: '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultItems() {
|
||||||
|
return [
|
||||||
|
{ type: 'more', buttons: [giftMoreButton()] },
|
||||||
|
{ type: 'pay_vip', pc_s: true, m_s: true, color: { color: '#f2c97d', bg: 'rgba(62,62,67,0.9)' } },
|
||||||
|
{ type: 'add', pc_s: true, m_s: false, color: emptyColor(), icon: 'fa fas fa-pencil-alt' },
|
||||||
|
{ type: 'service_qq', pc_s: false, m_s: false, color: emptyColor(), qq: '' },
|
||||||
|
{ type: 'service_wechat', pc_s: true, m_s: true, color: emptyColor(), wechat_img: '' },
|
||||||
|
{ type: 'theme_mode', pc_s: false, m_s: false, color: emptyColor() },
|
||||||
|
{ type: 'qrcode', pc_s: true, m_s: false, color: emptyColor(), desc: '在手机上浏览此页面' },
|
||||||
|
{ type: 'back_top', pc_s: true, m_s: true, color: emptyColor() },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSettings() {
|
||||||
|
return {
|
||||||
|
style: 'round',
|
||||||
|
position: 'bottom',
|
||||||
|
filter_blur: ['m_s'],
|
||||||
|
scroll_hide: false,
|
||||||
|
scroll_down_hide: true,
|
||||||
|
items: defaultItems(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化颜色组:确保 color/bg 都为字符串。 */
|
||||||
|
export function normalizeColor(color) {
|
||||||
|
const c = color && typeof color === 'object' ? color : {}
|
||||||
|
return { color: c.color || '', bg: c.bg || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用默认值补齐服务端返回的按钮(按服务端顺序)。 */
|
||||||
|
export function normalizeItems(items) {
|
||||||
|
const defaults = Object.fromEntries(defaultItems().map((item) => [item.type, item]))
|
||||||
|
const source = Array.isArray(items) && items.length ? items : defaultItems()
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const raw of source) {
|
||||||
|
const base = defaults[raw?.type]
|
||||||
|
if (!base || seen.has(raw.type)) continue
|
||||||
|
seen.add(raw.type)
|
||||||
|
if (raw.type === 'more') {
|
||||||
|
const buttons = (Array.isArray(raw.buttons) && raw.buttons.length ? raw.buttons : base.buttons).map((btn) => ({
|
||||||
|
...defaultMoreButton(),
|
||||||
|
...btn,
|
||||||
|
link: { url: btn?.link?.url || '', target: btn?.link?.target || '' },
|
||||||
|
color: normalizeColor(btn?.color),
|
||||||
|
}))
|
||||||
|
out.push({ type: 'more', buttons })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.push({ ...base, ...raw, color: normalizeColor(raw.color) })
|
||||||
|
}
|
||||||
|
for (const item of defaultItems()) {
|
||||||
|
if (!seen.has(item.type)) out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -60,17 +60,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Number -->
|
<!-- Number -->
|
||||||
<n-input-number
|
<div v-else-if="schema.type === 'number' || schema.type === 'spinner'" class="aksrt-field__number">
|
||||||
v-else-if="schema.type === 'number' || schema.type === 'spinner'"
|
<n-input-number
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:min="schema.min"
|
:min="schema.min"
|
||||||
:max="schema.max"
|
:max="schema.max"
|
||||||
:step="schema.step || 1"
|
:step="schema.step || 1"
|
||||||
:placeholder="schema.placeholder || ''"
|
:placeholder="schema.placeholder || ''"
|
||||||
:disabled="schema.disabled"
|
:disabled="schema.disabled"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@update:value="emit('update:modelValue', $event)"
|
@update:value="emit('update:modelValue', $event)"
|
||||||
/>
|
/>
|
||||||
|
<span v-if="schema.unit" class="aksrt-field__unit">{{ schema.unit }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Select -->
|
<!-- Select -->
|
||||||
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
|
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
|
||||||
@@ -142,6 +144,22 @@
|
|||||||
@update:value="emit('update:modelValue', $event)"
|
@update:value="emit('update:modelValue', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Page Select (async page list) -->
|
||||||
|
<n-select
|
||||||
|
v-else-if="schema.type === 'page_select'"
|
||||||
|
:value="modelValue"
|
||||||
|
:options="pageOptions"
|
||||||
|
:loading="pageLoading"
|
||||||
|
:placeholder="schema.placeholder || '选择页面'"
|
||||||
|
:multiple="schema.multiple === true"
|
||||||
|
:filterable="true"
|
||||||
|
:remote="true"
|
||||||
|
:clearable="schema.clearable !== false"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
@search="handlePageSearch"
|
||||||
|
@update:value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Radio group -->
|
<!-- Radio group -->
|
||||||
<n-radio-group
|
<n-radio-group
|
||||||
v-else-if="schema.type === 'radio'"
|
v-else-if="schema.type === 'radio'"
|
||||||
@@ -273,6 +291,17 @@
|
|||||||
v-html="schema.content || modelValue || ''"
|
v-html="schema.content || modelValue || ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Action button -->
|
||||||
|
<n-button
|
||||||
|
v-else-if="schema.type === 'button'"
|
||||||
|
:type="schema.buttonType || 'default'"
|
||||||
|
:loading="schema.loading"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
@click="emit('action', schema.key)"
|
||||||
|
>
|
||||||
|
{{ schema.buttonText || schema.label || '执行' }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<!-- Date picker -->
|
<!-- Date picker -->
|
||||||
<n-date-picker
|
<n-date-picker
|
||||||
v-else-if="schema.type === 'date'"
|
v-else-if="schema.type === 'date'"
|
||||||
@@ -321,6 +350,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Between number (min/max pair, Zibll between_number) -->
|
||||||
|
<div v-else-if="schema.type === 'between_number'" class="aksrt-field__between">
|
||||||
|
<n-input-number
|
||||||
|
:value="betweenValue('min')"
|
||||||
|
:min="schema.min"
|
||||||
|
:max="schema.max"
|
||||||
|
:step="schema.step || 1"
|
||||||
|
:placeholder="schema.minPlaceholder || '最小'"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
style="flex: 1"
|
||||||
|
@update:value="updateBetween('min', $event)"
|
||||||
|
/>
|
||||||
|
<span class="aksrt-field__between-sep">—</span>
|
||||||
|
<n-input-number
|
||||||
|
:value="betweenValue('max')"
|
||||||
|
:min="schema.min"
|
||||||
|
:max="schema.max"
|
||||||
|
:step="schema.step || 1"
|
||||||
|
:placeholder="schema.maxPlaceholder || '最大'"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
style="flex: 1"
|
||||||
|
@update:value="updateBetween('max', $event)"
|
||||||
|
/>
|
||||||
|
<span v-if="schema.unit" class="aksrt-field__between-unit">{{ schema.unit }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Spacing (top right bottom left) -->
|
<!-- Spacing (top right bottom left) -->
|
||||||
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
|
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
|
||||||
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
|
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
|
||||||
@@ -421,13 +476,43 @@
|
|||||||
|
|
||||||
<!-- Fieldset (single nested object) -->
|
<!-- Fieldset (single nested object) -->
|
||||||
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
|
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
|
||||||
<AksField
|
<template v-if="fieldsetSections.length">
|
||||||
v-for="field in visibleNestedFields"
|
<div
|
||||||
:key="field.key"
|
v-for="(section, sIndex) in fieldsetSections"
|
||||||
:schema="field"
|
:key="section.title"
|
||||||
:model-value="fieldsetValue(field)"
|
class="aksrt-field__accordion-section"
|
||||||
@update:model-value="updateFieldsetValue(field.key, $event)"
|
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
|
||||||
/>
|
>
|
||||||
|
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
|
||||||
|
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
|
||||||
|
<FaIcon
|
||||||
|
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
|
||||||
|
:size="12"
|
||||||
|
class="aksrt-field__accordion-section-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
|
||||||
|
<AksField
|
||||||
|
v-for="field in sectionFields(section)"
|
||||||
|
:key="field.key"
|
||||||
|
:schema="field"
|
||||||
|
:model-value="fieldsetValue(field)"
|
||||||
|
:form-data="formData"
|
||||||
|
@update:model-value="updateFieldsetValue(field.key, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="!fieldsetSections.length">
|
||||||
|
<AksField
|
||||||
|
v-for="field in visibleNestedFields"
|
||||||
|
:key="field.key"
|
||||||
|
:schema="field"
|
||||||
|
:model-value="fieldsetValue(field)"
|
||||||
|
:form-data="formData"
|
||||||
|
@update:model-value="updateFieldsetValue(field.key, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
|
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
|
||||||
@@ -445,43 +530,68 @@
|
|||||||
<n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button>
|
<n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="aksrt-field__sortable-body">
|
<div v-if="item.fields && item.fields.length" class="aksrt-field__sortable-body">
|
||||||
<AksField v-for="field in item.fields || []" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" @update:model-value="updateSortable(item.key, field.key, $event)" />
|
<AksField v-for="field in item.fields" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" :form-data="formData" @update:model-value="updateSortable(item.key, field.key, $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Repeater (array of objects) — inline expand (default) -->
|
<!-- Repeater (array of objects) — inline expand (default, draggable) -->
|
||||||
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
|
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
|
||||||
<div
|
<draggable
|
||||||
v-for="(item, index) in (modelValue || [])"
|
v-model="repeaterDragList"
|
||||||
:key="item._id || index"
|
:item-key="repeaterDragKey"
|
||||||
class="aksrt-field__repeater-item"
|
handle=".aksrt-field__repeater-handle"
|
||||||
|
ghost-class="aksrt-field__repeater-ghost"
|
||||||
|
animation="180"
|
||||||
>
|
>
|
||||||
<div class="aksrt-field__repeater-header">
|
<template #item="{ element, index }">
|
||||||
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
|
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
|
||||||
<n-button
|
<div
|
||||||
size="tiny"
|
class="aksrt-field__repeater-header"
|
||||||
type="error"
|
:class="{ 'is-clickable': repeaterCollapsible }"
|
||||||
text
|
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
|
||||||
:disabled="schema.disabled || reachedRepeaterMinimum"
|
>
|
||||||
@click="removeRepeaterItem(index)"
|
<div class="aksrt-field__repeater-title">
|
||||||
>
|
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true" @click.stop></i>
|
||||||
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
|
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
|
||||||
</n-button>
|
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
|
||||||
</div>
|
{{ repeaterItemSummary(element) }}
|
||||||
<div class="aksrt-field__repeater-fields">
|
</span>
|
||||||
<template v-for="field in (schema.fields || [])" :key="field.key">
|
</div>
|
||||||
<AksField
|
<div class="aksrt-field__repeater-header-actions" @click.stop>
|
||||||
v-if="matchDependency(field.dependsOn, item)"
|
<FaIcon
|
||||||
:schema="field"
|
v-if="repeaterCollapsible"
|
||||||
:model-value="item[field.key]"
|
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
|
||||||
:disabled="schema.disabled"
|
:size="12"
|
||||||
@update:model-value="updateRepeaterItem(index, field.key, $event)"
|
class="aksrt-field__repeater-toggle"
|
||||||
/>
|
/>
|
||||||
</template>
|
<n-button
|
||||||
</div>
|
size="tiny"
|
||||||
</div>
|
type="error"
|
||||||
|
text
|
||||||
|
:disabled="schema.disabled || reachedRepeaterMinimum"
|
||||||
|
@click="removeRepeaterItem(index)"
|
||||||
|
>
|
||||||
|
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
|
||||||
|
<template v-for="field in (schema.fields || [])" :key="field.key">
|
||||||
|
<AksField
|
||||||
|
v-if="matchDependency(field.dependsOn, element)"
|
||||||
|
:schema="field"
|
||||||
|
:model-value="element[field.key]"
|
||||||
|
:form-data="formData"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
@update:model-value="updateRepeaterItem(index, field.key, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
dashed
|
dashed
|
||||||
@@ -495,9 +605,9 @@
|
|||||||
|
|
||||||
<!-- Repeater list-modal: compact list + modal editor -->
|
<!-- Repeater list-modal: compact list + modal editor -->
|
||||||
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal">
|
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal">
|
||||||
<div v-if="(modelValue || []).length" class="aksrt-field__repeater-list">
|
<div v-if="repeaterItems.length" class="aksrt-field__repeater-list">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in (modelValue || [])"
|
v-for="(item, index) in repeaterItems"
|
||||||
:key="item._id || index"
|
:key="item._id || index"
|
||||||
class="aksrt-field__repeater-list-item"
|
class="aksrt-field__repeater-list-item"
|
||||||
>
|
>
|
||||||
@@ -512,7 +622,7 @@
|
|||||||
<FaIcon icon="fa fas fa-arrow-up" :size="12" />
|
<FaIcon icon="fa fas fa-arrow-up" :size="12" />
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="index < (modelValue || []).length - 1"
|
v-if="index < repeaterItems.length - 1"
|
||||||
size="tiny"
|
size="tiny"
|
||||||
quaternary
|
quaternary
|
||||||
:disabled="schema.disabled"
|
:disabled="schema.disabled"
|
||||||
@@ -591,6 +701,7 @@
|
|||||||
:key="field.key"
|
:key="field.key"
|
||||||
:schema="field"
|
:schema="field"
|
||||||
:model-value="repeaterDraft[field.key]"
|
:model-value="repeaterDraft[field.key]"
|
||||||
|
:form-data="formData"
|
||||||
@update:model-value="updateRepeaterDraft(field.key, $event)"
|
@update:model-value="updateRepeaterDraft(field.key, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,6 +741,8 @@ import FaIconPicker from './FaIconPicker.vue'
|
|||||||
import AksMediaField from './AksMediaField.vue'
|
import AksMediaField from './AksMediaField.vue'
|
||||||
import AksGalleryField from './AksGalleryField.vue'
|
import AksGalleryField from './AksGalleryField.vue'
|
||||||
import AksCodeEditor from './AksCodeEditor.vue'
|
import AksCodeEditor from './AksCodeEditor.vue'
|
||||||
|
import { buildFieldsetSections } from './fieldset-sections.js'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -641,11 +754,13 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
/** Current field value */
|
/** Current field value */
|
||||||
modelValue: { default: null },
|
modelValue: { default: null },
|
||||||
|
/** Whole-form data — used to resolve dynamic options (schema.options as string = formData key). */
|
||||||
|
formData: { type: Object, default: () => ({}) },
|
||||||
/** Error message to display */
|
/** Error message to display */
|
||||||
errorMessage: { type: String, default: '' },
|
errorMessage: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue', 'action'])
|
||||||
|
|
||||||
const secretMarkedForClear = computed(() => props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
|
const secretMarkedForClear = computed(() => props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
|
||||||
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
|
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
|
||||||
@@ -655,9 +770,90 @@ function toggleSecretClear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0)
|
const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0)
|
||||||
|
/** 数组安全视图:非数组(如旧版字符串值)一律按空列表渲染,避免逐字符假条目。 */
|
||||||
|
const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||||
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
|
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
|
||||||
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
|
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
|
||||||
|
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
|
||||||
|
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
|
||||||
|
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
|
||||||
|
const toggledRepeaterItems = ref(new Set())
|
||||||
|
|
||||||
|
function isRepeaterItemOpen(index) {
|
||||||
|
if (!repeaterCollapsible.value) return true
|
||||||
|
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepeaterItem(index) {
|
||||||
|
const next = new Set(toggledRepeaterItems.value)
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
toggledRepeaterItems.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
|
||||||
|
function repeaterItemSummary(item) {
|
||||||
|
if (!repeaterCollapsible.value || !item) return ''
|
||||||
|
const cfg = getRepeaterPreviewConfig()
|
||||||
|
const titleKey = cfg.titleKey || 'title'
|
||||||
|
const title = String(item[titleKey] ?? '').trim()
|
||||||
|
const prefix = String(cfg.prefix || '')
|
||||||
|
if (title === '') return prefix
|
||||||
|
const desc = repeaterItemDuration(item)
|
||||||
|
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
|
||||||
|
function repeaterItemDuration(item) {
|
||||||
|
const cfg = getRepeaterPreviewConfig()
|
||||||
|
const descKey = cfg.descKey
|
||||||
|
if (!descKey) return ''
|
||||||
|
const raw = item[descKey]
|
||||||
|
if (raw === '' || raw == null) return ''
|
||||||
|
const amount = Number(raw)
|
||||||
|
if (!Number.isFinite(amount)) return String(raw)
|
||||||
|
if (amount <= 0) return '永久'
|
||||||
|
const unitKey = cfg.descUnitKey
|
||||||
|
if (!unitKey) return String(raw)
|
||||||
|
const unit = String(item[unitKey] || '')
|
||||||
|
if (unit === 'month') return `${amount} 个月`
|
||||||
|
if (unit === 'day') return `${amount} 天`
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
|
||||||
|
const repeaterDragList = computed({
|
||||||
|
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||||
|
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行 key:优先用行自身 _id;服务端历史数据没有 _id 时按对象引用派生稳定 key,
|
||||||
|
* 避免每次输入都重算 key 导致子字段重挂载、输入焦点丢失。
|
||||||
|
*/
|
||||||
|
const dragRowKeys = new WeakMap()
|
||||||
|
let dragRowKeySeed = 0
|
||||||
|
function repeaterDragKey(item) {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
if (item._id) return item._id
|
||||||
|
let key = dragRowKeys.get(item)
|
||||||
|
if (!key) {
|
||||||
|
key = `rpt_drag_${++dragRowKeySeed}`
|
||||||
|
dragRowKeys.set(item, key)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return String(item)
|
||||||
|
}
|
||||||
|
|
||||||
function fieldsetValue(field) {
|
function fieldsetValue(field) {
|
||||||
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
? props.modelValue
|
? props.modelValue
|
||||||
@@ -665,6 +861,35 @@ function fieldsetValue(field) {
|
|||||||
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
|
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
|
||||||
|
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
const fieldsetSections = computed(() => {
|
||||||
|
return buildFieldsetSections(props.schema)
|
||||||
|
})
|
||||||
|
|
||||||
|
const openFieldsetSections = ref(new Set())
|
||||||
|
|
||||||
|
function isFieldsetSectionOpen(index) {
|
||||||
|
return openFieldsetSections.value.has(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */
|
||||||
|
function sectionFields(section) {
|
||||||
|
return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFieldsetSection(index) {
|
||||||
|
const next = new Set(openFieldsetSections.value)
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
openFieldsetSections.value = next
|
||||||
|
}
|
||||||
|
|
||||||
function updateFieldsetValue(key, value) {
|
function updateFieldsetValue(key, value) {
|
||||||
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
? props.modelValue
|
? props.modelValue
|
||||||
@@ -672,10 +897,22 @@ function updateFieldsetValue(key, value) {
|
|||||||
emit('update:modelValue', { ...current, [key]: value })
|
emit('update:modelValue', { ...current, [key]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize options: support both string[] and {label, value}[] */
|
/** Normalize options: support string[], {label, value}[], and dynamic formData-key references */
|
||||||
const normalizedOptions = computed(() => {
|
const normalizedOptions = computed(() => {
|
||||||
const opts = props.schema.options || []
|
const opts = props.schema.options
|
||||||
return opts.map(opt => {
|
if (typeof opts === 'string') {
|
||||||
|
// 动态选项:引用 formData 中同名字段(如 custom_filter 维度定义 [{key,name,vals}])。
|
||||||
|
const source = props.formData?.[opts]
|
||||||
|
if (!Array.isArray(source)) return []
|
||||||
|
return source
|
||||||
|
.map(item => {
|
||||||
|
const value = String(item?.key ?? '')
|
||||||
|
if (value === '') return null
|
||||||
|
return { label: String(item?.name ?? item?.key ?? value), value }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
return (Array.isArray(opts) ? opts : []).map(opt => {
|
||||||
if (typeof opt === 'string') return { label: opt, value: opt }
|
if (typeof opt === 'string') return { label: opt, value: opt }
|
||||||
if (typeof opt === 'object' && opt !== null) {
|
if (typeof opt === 'object' && opt !== null) {
|
||||||
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
|
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
|
||||||
@@ -796,6 +1033,9 @@ onMounted(() => {
|
|||||||
if (props.schema.type === 'async_select') {
|
if (props.schema.type === 'async_select') {
|
||||||
loadAsyncOptions()
|
loadAsyncOptions()
|
||||||
}
|
}
|
||||||
|
if (props.schema.type === 'page_select') {
|
||||||
|
loadPages()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ============ User Select (async search) ============
|
// ============ User Select (async search) ============
|
||||||
@@ -839,6 +1079,31 @@ function handleUserSearch(query) {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Page Select (async page list) ============
|
||||||
|
const pageOptions = ref([])
|
||||||
|
const pageLoading = ref(false)
|
||||||
|
let pageSearchTimer = null
|
||||||
|
|
||||||
|
async function loadPages(query = '') {
|
||||||
|
pageLoading.value = true
|
||||||
|
try {
|
||||||
|
const selected = props.modelValue ? [props.modelValue] : []
|
||||||
|
const res = await api.searchPageDisplayOptions('pages', query, query ? [] : selected)
|
||||||
|
const items = res?.data || res || []
|
||||||
|
pageOptions.value = Array.isArray(items) ? items : []
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AksField] page load failed:', e)
|
||||||
|
pageOptions.value = []
|
||||||
|
} finally {
|
||||||
|
pageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSearch(query) {
|
||||||
|
clearTimeout(pageSearchTimer)
|
||||||
|
pageSearchTimer = setTimeout(() => loadPages(query), 250)
|
||||||
|
}
|
||||||
|
|
||||||
const fieldClasses = computed(() => ({
|
const fieldClasses = computed(() => ({
|
||||||
'aksrt-field--disabled': props.schema.disabled,
|
'aksrt-field--disabled': props.schema.disabled,
|
||||||
'aksrt-field--inline': props.schema.inline,
|
'aksrt-field--inline': props.schema.inline,
|
||||||
@@ -859,6 +1124,21 @@ function updateDimension(part, value) {
|
|||||||
emit('update:modelValue', { ...current, [part]: value })
|
emit('update:modelValue', { ...current, [part]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Between Number (min/max pair) ============
|
||||||
|
function betweenValue(part) {
|
||||||
|
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: {}
|
||||||
|
return current[part] !== undefined ? current[part] : (props.schema.default?.[part] ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBetween(part, value) {
|
||||||
|
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: {}
|
||||||
|
emit('update:modelValue', { ...current, [part]: value })
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Spacing ============
|
// ============ Spacing ============
|
||||||
function updateSpacing(part, value) {
|
function updateSpacing(part, value) {
|
||||||
const current = props.modelValue || {}
|
const current = props.modelValue || {}
|
||||||
@@ -901,6 +1181,8 @@ const repeaterDraft = ref({})
|
|||||||
function createRepeaterItem() {
|
function createRepeaterItem() {
|
||||||
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
||||||
for (const field of (props.schema.fields || [])) {
|
for (const field of (props.schema.fields || [])) {
|
||||||
|
// UI-only chrome rows (notice / alert) are presentation only.
|
||||||
|
if (field.ui_only) continue
|
||||||
if (field.default !== undefined) {
|
if (field.default !== undefined) {
|
||||||
newItem[field.key] = field.default
|
newItem[field.key] = field.default
|
||||||
} else if (field.type === 'switch') {
|
} else if (field.type === 'switch') {
|
||||||
@@ -930,7 +1212,16 @@ function removeRepeaterItem(index) {
|
|||||||
|
|
||||||
function updateRepeaterItem(index, fieldKey, value) {
|
function updateRepeaterItem(index, fieldKey, value) {
|
||||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
current[index] = { ...current[index], [fieldKey]: value }
|
const row = current[index]
|
||||||
|
if (row && typeof row === 'object') {
|
||||||
|
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
|
||||||
|
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
|
||||||
|
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
|
||||||
|
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
|
||||||
|
row[fieldKey] = value
|
||||||
|
} else {
|
||||||
|
current[index] = { [fieldKey]: value }
|
||||||
|
}
|
||||||
emit('update:modelValue', current)
|
emit('update:modelValue', current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,7 +1278,7 @@ function openRepeaterModal(index) {
|
|||||||
if (index < 0 && reachedRepeaterMaximum.value) return
|
if (index < 0 && reachedRepeaterMaximum.value) return
|
||||||
if (props.schema.disabled) return
|
if (props.schema.disabled) return
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const item = (props.modelValue || [])[index] || {}
|
const item = (Array.isArray(props.modelValue) ? props.modelValue : [])[index] || {}
|
||||||
repeaterDraft.value = { ...createRepeaterItem(), ...item }
|
repeaterDraft.value = { ...createRepeaterItem(), ...item }
|
||||||
repeaterEditingIndex.value = index
|
repeaterEditingIndex.value = index
|
||||||
} else {
|
} else {
|
||||||
@@ -1032,6 +1323,15 @@ function confirmRepeaterModal() {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
|
||||||
|
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
|
||||||
|
.aksrt-field--group,
|
||||||
|
.aksrt-field--repeater,
|
||||||
|
.aksrt-field--accordion,
|
||||||
|
.aksrt-field--fieldset {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-field__input-wrap {
|
.aksrt-field__input-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1104,6 +1404,38 @@ function confirmRepeaterModal() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Between number (min/max) */
|
||||||
|
.aksrt-field__between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__unit {
|
||||||
|
color: var(--aksrt-color-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__between-sep {
|
||||||
|
color: var(--aksrt-color-border-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__between-unit {
|
||||||
|
color: var(--aksrt-color-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Spacing field */
|
/* Spacing field */
|
||||||
.aksrt-field__spacing {
|
.aksrt-field__spacing {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1248,7 +1580,6 @@ function confirmRepeaterModal() {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--aksrt-color-canvas);
|
background: var(--aksrt-color-canvas);
|
||||||
cursor: move;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field__sortable-body {
|
.aksrt-field__sortable-body {
|
||||||
@@ -1266,6 +1597,48 @@ function confirmRepeaterModal() {
|
|||||||
background: var(--aksrt-color-canvas);
|
background: var(--aksrt-color-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--aksrt-color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--aksrt-color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--aksrt-color-text);
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section-head:hover {
|
||||||
|
border-color: var(--aksrt-color-primary);
|
||||||
|
color: var(--aksrt-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section-title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--aksrt-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-field__repeater {
|
.aksrt-field__repeater {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1283,9 +1656,68 @@ function confirmRepeaterModal() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-header.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: -4px -4px 0;
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-header.is-clickable:hover .aksrt-field__repeater-index {
|
||||||
|
color: var(--aksrt-color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-toggle {
|
||||||
|
color: var(--aksrt-color-text-secondary);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-summary {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aksrt-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-item.is-collapsible {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-item.is-collapsed .aksrt-field__repeater-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--aksrt-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-field__repeater-ghost {
|
||||||
|
opacity: 0.45;
|
||||||
|
border-color: var(--aksrt-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-field__repeater-index {
|
.aksrt-field__repeater-index {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -56,24 +56,60 @@
|
|||||||
<template v-if="group.fields && !hasGroupSlot(group)">
|
<template v-if="group.fields && !hasGroupSlot(group)">
|
||||||
<template v-for="field in group.fields" :key="field.key">
|
<template v-for="field in group.fields" :key="field.key">
|
||||||
<template v-if="isFieldVisible(field)">
|
<template v-if="isFieldVisible(field)">
|
||||||
|
<!-- Tabbed: renders tabs whose sub-fields are flat top-level keys -->
|
||||||
|
<div
|
||||||
|
v-if="field.type === 'tabbed' && (field.tabs || []).length"
|
||||||
|
class="aksrt-form-renderer__tabbed"
|
||||||
|
>
|
||||||
|
<n-tabs type="line" animated>
|
||||||
|
<n-tab-pane
|
||||||
|
v-for="tab in field.tabs"
|
||||||
|
:key="tab.key || tab.title"
|
||||||
|
:name="tabName(tab)"
|
||||||
|
:tab="tabName(tab)"
|
||||||
|
>
|
||||||
|
<template v-for="sub in (tab.fields || [])" :key="sub.key">
|
||||||
|
<AksField
|
||||||
|
v-if="isFieldVisible(sub)"
|
||||||
|
:schema="sub"
|
||||||
|
:model-value="getFieldValue(sub.key)"
|
||||||
|
:form-data="formData"
|
||||||
|
:error-message="errors?.[sub.key]"
|
||||||
|
@update:model-value="setFieldValue(sub.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
<AksSorterField
|
||||||
|
v-else-if="field.type === 'custom' && field.renderer === 'sorter'"
|
||||||
|
:model-value="getFieldValue(field.key) || {}"
|
||||||
|
:options="field.options || []"
|
||||||
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
/>
|
||||||
<slot
|
<slot
|
||||||
v-if="field.type === 'custom'"
|
v-else-if="field.type === 'custom'"
|
||||||
:name="`field-${field.key}`"
|
:name="`field-${field.key}`"
|
||||||
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
||||||
>
|
>
|
||||||
<AksField
|
<AksField
|
||||||
:schema="field"
|
:schema="field"
|
||||||
:model-value="getFieldValue(field.key)"
|
:model-value="getFieldValue(field.key)"
|
||||||
|
:form-data="formData"
|
||||||
:error-message="errors?.[field.key]"
|
:error-message="errors?.[field.key]"
|
||||||
@update:model-value="setFieldValue(field.key, $event)"
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<AksField
|
<AksField
|
||||||
v-else
|
v-else
|
||||||
:schema="field"
|
:schema="field"
|
||||||
:model-value="getFieldValue(field.key)"
|
:model-value="getFieldValue(field.key)"
|
||||||
|
:form-data="formData"
|
||||||
:error-message="errors?.[field.key]"
|
:error-message="errors?.[field.key]"
|
||||||
@update:model-value="setFieldValue(field.key, $event)"
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -92,26 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky save bar -->
|
<!-- Sticky save bar -->
|
||||||
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
|
<AksSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
|
||||||
<div class="aksrt-form-renderer__save-bar__inner">
|
|
||||||
<div class="aksrt-form-renderer__save-bar__info">
|
|
||||||
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
|
|
||||||
<span>您有未保存的更改</span>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-renderer__save-bar__actions">
|
|
||||||
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
|
|
||||||
<n-button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
:loading="saving"
|
|
||||||
@click="$emit('save')"
|
|
||||||
>
|
|
||||||
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
|
|
||||||
保存设置
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -129,8 +146,10 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, computed, useSlots } from 'vue'
|
import { ref, computed, useSlots } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
|
import AksSaveBar from './AksSaveBar.vue'
|
||||||
import AksFieldGroup from './AksFieldGroup.vue'
|
import AksFieldGroup from './AksFieldGroup.vue'
|
||||||
import AksField from './AksField.vue'
|
import AksField from './AksField.vue'
|
||||||
|
import AksSorterField from './AksSorterField.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** Page title */
|
/** Page title */
|
||||||
@@ -161,7 +180,7 @@ const props = defineProps({
|
|||||||
errors: { type: Object, default: () => ({}) },
|
errors: { type: Object, default: () => ({}) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:formData', 'save', 'reload'])
|
const emit = defineEmits(['update:formData', 'save', 'reload', 'field-action'])
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -212,6 +231,18 @@ function isFieldVisible(field) {
|
|||||||
return matchDependsOn(field.dependsOn)
|
return matchDependsOn(field.dependsOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
|
||||||
|
* display name) so renaming a level renames its tab; `titleFallback` is used
|
||||||
|
* while that value is still empty.
|
||||||
|
*/
|
||||||
|
function tabName(tab) {
|
||||||
|
if (!tab) return ''
|
||||||
|
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
|
||||||
|
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
|
||||||
|
return label || tab.titleFallback || tab.title || tab.key || ''
|
||||||
|
}
|
||||||
|
|
||||||
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
||||||
function isGroupVisible(group) {
|
function isGroupVisible(group) {
|
||||||
return matchDependsOn(group.dependsOn)
|
return matchDependsOn(group.dependsOn)
|
||||||
@@ -266,6 +297,15 @@ const filteredSchema = computed(() => {
|
|||||||
// Filter fields by label, key, or help text matching query
|
// Filter fields by label, key, or help text matching query
|
||||||
const matchingFields = group.fields.filter(field => {
|
const matchingFields = group.fields.filter(field => {
|
||||||
if (groupMatch) return true
|
if (groupMatch) return true
|
||||||
|
if (field.type === 'tabbed' && Array.isArray(field.tabs)) {
|
||||||
|
return (field.label || '').toLowerCase().includes(q)
|
||||||
|
|| field.tabs.some(tab => (tab.fields || []).some(sub =>
|
||||||
|
(sub.label || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.key || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.help || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.description || '').toLowerCase().includes(q)
|
||||||
|
))
|
||||||
|
}
|
||||||
return (field.label || '').toLowerCase().includes(q)
|
return (field.label || '').toLowerCase().includes(q)
|
||||||
|| (field.key || '').toLowerCase().includes(q)
|
|| (field.key || '').toLowerCase().includes(q)
|
||||||
|| (field.help || '').toLowerCase().includes(q)
|
|| (field.help || '').toLowerCase().includes(q)
|
||||||
@@ -316,6 +356,10 @@ const filteredSchema = computed(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__tabbed {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__search {
|
.aksrt-form-renderer__search {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
@@ -345,53 +389,6 @@ const filteredSchema = computed(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky save bar */
|
|
||||||
.aksrt-form-renderer__save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: var(--aksrt-color-surface);
|
|
||||||
border-top: 1px solid var(--aksrt-color-border);
|
|
||||||
box-shadow: var(--aksrt-shadow-floating);
|
|
||||||
animation: slideUp 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 12px 28px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #d48806;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.aksrt-form-renderer__header {
|
.aksrt-form-renderer__header {
|
||||||
@@ -403,19 +400,5 @@ const filteredSchema = computed(() => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__inner {
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px 14px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions .n-button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
|
||||||
|
<div class="aksrt-form-renderer__save-bar__inner">
|
||||||
|
<div class="aksrt-form-renderer__save-bar__info">
|
||||||
|
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
|
||||||
|
<span>您有未保存的更改</span>
|
||||||
|
</div>
|
||||||
|
<div class="aksrt-form-renderer__save-bar__actions">
|
||||||
|
<n-button size="small" :disabled="saving" @click="emit('reload')">放弃更改</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
|
||||||
|
保存设置
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* AksSaveBar — the admin-wide sticky save bar (shown only when dirty).
|
||||||
|
* Shared by AksFormRenderer (schema pages) and custom settings pages.
|
||||||
|
*/
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
dirty: { type: Boolean, default: false },
|
||||||
|
saving: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'reload'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aksrt-form-renderer__save-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--aksrt-color-surface);
|
||||||
|
border-top: 1px solid var(--aksrt-color-border);
|
||||||
|
box-shadow: var(--aksrt-shadow-floating);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__save-bar__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px 28px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__save-bar__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #d48806;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__save-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.aksrt-form-renderer__save-bar__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__save-bar__actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-form-renderer__save-bar__actions .n-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="aksrt-sorter">
|
||||||
|
<div class="aksrt-sorter__column">
|
||||||
|
<div class="aksrt-sorter__title">
|
||||||
|
<span>已启用</span>
|
||||||
|
<n-tag size="small" type="success" :bordered="false">{{ columns.enabled.length }}</n-tag>
|
||||||
|
</div>
|
||||||
|
<draggable
|
||||||
|
v-model="columns.enabled"
|
||||||
|
item-key="value"
|
||||||
|
group="aksrt-sorter"
|
||||||
|
handle=".aksrt-sorter__handle"
|
||||||
|
ghost-class="aksrt-sorter__ghost"
|
||||||
|
animation="180"
|
||||||
|
class="aksrt-sorter__list"
|
||||||
|
@change="commit"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="aksrt-sorter__item">
|
||||||
|
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
|
||||||
|
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
|
||||||
|
<span>{{ element.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<n-empty v-if="columns.enabled.length === 0" size="small" description="暂无启用项" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="aksrt-sorter__column aksrt-sorter__column--disabled">
|
||||||
|
<div class="aksrt-sorter__title">
|
||||||
|
<span>未启用</span>
|
||||||
|
<n-tag size="small" :bordered="false">{{ columns.disabled.length }}</n-tag>
|
||||||
|
</div>
|
||||||
|
<draggable
|
||||||
|
v-model="columns.disabled"
|
||||||
|
item-key="value"
|
||||||
|
group="aksrt-sorter"
|
||||||
|
handle=".aksrt-sorter__handle"
|
||||||
|
ghost-class="aksrt-sorter__ghost"
|
||||||
|
animation="180"
|
||||||
|
class="aksrt-sorter__list"
|
||||||
|
@change="commit"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="aksrt-sorter__item">
|
||||||
|
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
|
||||||
|
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
|
||||||
|
<span>{{ element.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<n-empty v-if="columns.disabled.length === 0" size="small" description="暂无未启用项" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, default: () => ({}) },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const columns = reactive({ enabled: [], disabled: [] })
|
||||||
|
let syncing = false
|
||||||
|
|
||||||
|
function keysOf(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(String)
|
||||||
|
return value && typeof value === 'object' ? Object.keys(value) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncColumns() {
|
||||||
|
syncing = true
|
||||||
|
const byKey = new Map(props.options.map(option => [String(option.value), option]))
|
||||||
|
const used = new Set()
|
||||||
|
const normalize = keys => keys.filter(key => byKey.has(key) && !used.has(key)).map(key => {
|
||||||
|
used.add(key)
|
||||||
|
return byKey.get(key)
|
||||||
|
})
|
||||||
|
columns.enabled = normalize(keysOf(props.modelValue?.enabled))
|
||||||
|
columns.disabled = normalize(keysOf(props.modelValue?.disabled))
|
||||||
|
for (const option of props.options) {
|
||||||
|
if (!used.has(String(option.value))) columns.disabled.push(option)
|
||||||
|
}
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
if (syncing) return
|
||||||
|
const toMap = items => Object.fromEntries(items.map(item => [String(item.value), String(item.label)]))
|
||||||
|
emit('update:modelValue', {
|
||||||
|
enabled: toMap(columns.enabled),
|
||||||
|
disabled: toMap(columns.disabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.modelValue, props.options], syncColumns, { immediate: true, deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aksrt-sorter { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
|
||||||
|
.aksrt-sorter__column { min-height:210px; padding:12px; border:1px solid var(--aksrt-color-border); border-radius:6px; background:var(--aksrt-color-surface); }
|
||||||
|
.aksrt-sorter__column--disabled { background:var(--aksrt-color-canvas); }
|
||||||
|
.aksrt-sorter__title { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; font-weight:600; }
|
||||||
|
.aksrt-sorter__list { display:grid; gap:7px; min-height:44px; }
|
||||||
|
.aksrt-sorter__item { display:flex; align-items:center; gap:9px; min-height:38px; padding:7px 10px; border:1px solid var(--aksrt-color-border); border-radius:4px; background:var(--aksrt-color-surface); }
|
||||||
|
.aksrt-sorter__handle { color:var(--aksrt-color-text-tertiary); cursor:grab; }
|
||||||
|
.aksrt-sorter__ghost { opacity:.45; border-color:var(--aksrt-color-primary); }
|
||||||
|
@media (max-width:720px) { .aksrt-sorter { grid-template-columns:1fr; } }
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-fa-icon-picker">
|
<div class="aksrt-fa-icon-picker">
|
||||||
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
|
<n-popover v-model:show="popoverShow" trigger="click" placement="bottom-start" :width="460" :style="{ padding: 0 }">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-input
|
<n-input
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
@clear="$emit('update:modelValue', '')"
|
@clear="$emit('update:modelValue', '')"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<FaIcon v-if="modelValue" :icon="modelValue" />
|
<IconValue v-if="modelValue" :value="modelValue" />
|
||||||
|
<template v-else></template>
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<FaIcon icon="fa fas fa-chevron-down" :size="12" />
|
<FaIcon icon="fa fas fa-chevron-down" :size="12" />
|
||||||
@@ -18,14 +19,9 @@
|
|||||||
</n-input>
|
</n-input>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__panel">
|
<div class="aksrt-icon-picker__panel" @click="onPanelClick">
|
||||||
<div class="aksrt-icon-picker__search">
|
<div class="aksrt-icon-picker__search">
|
||||||
<n-input
|
<n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
|
||||||
v-model:value="search"
|
|
||||||
placeholder="搜索图标..."
|
|
||||||
size="small"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<FaIcon icon="fa fas fa-search" :size="12" />
|
<FaIcon icon="fa fas fa-search" :size="12" />
|
||||||
</template>
|
</template>
|
||||||
@@ -33,23 +29,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__tabs">
|
<div class="aksrt-icon-picker__tabs">
|
||||||
<n-radio-group v-model:value="activePrefix" size="small">
|
<button
|
||||||
<n-radio-button value="fas">Solid</n-radio-button>
|
v-for="tab in tabs"
|
||||||
<n-radio-button value="far">Regular</n-radio-button>
|
:key="tab.id"
|
||||||
<n-radio-button value="fab">Brands</n-radio-button>
|
type="button"
|
||||||
</n-radio-group>
|
class="aksrt-icon-picker__tab"
|
||||||
|
:class="{ 'is-active': activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.title }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__grid" ref="gridRef">
|
<!-- Custom SVG tab -->
|
||||||
|
<div v-if="activeTab === 'custom'" class="aksrt-icon-picker__custom">
|
||||||
|
<n-input v-model:value="customCode" type="textarea" placeholder='<svg viewBox="0 0 1024 1024"><path d="…"></path></svg>' :rows="4" />
|
||||||
|
<div class="aksrt-icon-picker__custom-actions">
|
||||||
|
<span class="aksrt-icon-picker__custom-preview" v-html="customPreview"></span>
|
||||||
|
<n-button size="small" type="primary" :disabled="!customPreview" @click="insertCustom">
|
||||||
|
确认插入
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="sessionCustom.length"
|
||||||
|
class="aksrt-icon-picker__grid aksrt-icon-picker__grid--session"
|
||||||
|
data-aksrt-icon-grid="custom"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(svg, i) in sessionCustom"
|
||||||
|
:key="i"
|
||||||
|
class="aksrt-icon-picker__item"
|
||||||
|
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
|
||||||
|
title="customsvg"
|
||||||
|
:data-icon="`customsvg-${svg}`"
|
||||||
|
v-html="cleanSvg(svg)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Catalog tab -->
|
||||||
|
<div v-else class="aksrt-icon-picker__grid" data-aksrt-icon-grid="catalog">
|
||||||
<div
|
<div
|
||||||
v-for="icon in filteredIcons"
|
v-for="icon in filteredIcons"
|
||||||
:key="icon"
|
:key="icon"
|
||||||
class="aksrt-icon-picker__item"
|
class="aksrt-icon-picker__item"
|
||||||
:class="{ 'is-active': modelValue === `fa ${activePrefix} ${icon}` }"
|
:class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
|
||||||
:title="icon"
|
:title="icon"
|
||||||
@click="selectIcon(icon)"
|
:data-icon="composeIconValue(activeGroup, icon)"
|
||||||
>
|
>
|
||||||
<i :class="['fa', activePrefix, icon]"></i>
|
<i v-if="activeGroup.type === 'fa'" :class="[activeTab, 'fa-' + icon]"></i>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="aksrt-icon-picker__preview-svg"
|
||||||
|
v-html="zibsvgMarkup(`zibsvg-${icon}`)"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
|
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
|
||||||
未找到匹配图标
|
未找到匹配图标
|
||||||
@@ -62,11 +95,19 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* FaIconPicker — searchable FA5 icon picker.
|
* FaIconPicker — universal icon picker backed by the server catalog
|
||||||
* Renders an input that opens a popover grid of FA5 icons.
|
* (GET /aksrt/v1/icons/catalog): full FA5 free set, the theme's built-in
|
||||||
|
* SVG sprite keys and inline custom SVG values.
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
|
import IconValue from './IconValue.vue'
|
||||||
|
import api from '@/api/index.js'
|
||||||
|
import {
|
||||||
|
cleanSvg,
|
||||||
|
zibsvgMarkup,
|
||||||
|
composeIconValue,
|
||||||
|
} from '@/utils/icons.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -75,96 +116,75 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const FALLBACK_TABS = [
|
||||||
|
{ id: 'fas', title: 'Solid', type: 'fa', icons: [] },
|
||||||
|
{ id: 'far', title: 'Regular', type: 'fa', icons: [] },
|
||||||
|
{ id: 'fab', title: 'Brands', type: 'fa', icons: [] },
|
||||||
|
{ id: 'zibsvg', title: '内置 SVG', type: 'svg', icons: [] },
|
||||||
|
{ id: 'custom', title: '自定义', type: 'custom', icons: [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabs = ref(FALLBACK_TABS)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activePrefix = ref('fas')
|
const activeTab = ref('fas')
|
||||||
const gridRef = ref(null)
|
const customCode = ref('')
|
||||||
|
const sessionCustom = ref([])
|
||||||
|
const popoverShow = ref(false)
|
||||||
|
|
||||||
// Common FA5 icons subset — a curated list covering most admin UI needs.
|
const activeGroup = computed(() => tabs.value.find(t => t.id === activeTab.value) || tabs.value[0])
|
||||||
// Curated FA5 subsets for the three bundled font families.
|
|
||||||
const SOLID_ICONS = [
|
|
||||||
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-wrench',
|
|
||||||
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync-alt', 'fa-redo-alt', 'fa-undo',
|
|
||||||
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
|
|
||||||
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
|
|
||||||
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user', 'fa-user-circle',
|
|
||||||
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-id-badge',
|
|
||||||
'fa-globe', 'fa-globe', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
|
|
||||||
'fa-image', 'fa-image', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
|
|
||||||
'fa-folder', 'fa-folder-open', 'fa-download', 'fa-upload', 'fa-cloud',
|
|
||||||
'fa-database', 'fa-server', 'fa-hdd', 'fa-microchip', 'fa-microchip',
|
|
||||||
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
|
|
||||||
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
|
|
||||||
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard',
|
|
||||||
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
|
|
||||||
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
|
|
||||||
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard',
|
|
||||||
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-paint-brush',
|
|
||||||
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
|
|
||||||
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
|
|
||||||
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
|
|
||||||
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
|
|
||||||
'fa-calendar', 'fa-calendar', 'fa-history', 'fa-hourglass',
|
|
||||||
'fa-map-marker-alt', 'fa-map', 'fa-road', 'fa-compass',
|
|
||||||
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
|
|
||||||
'fa-database', 'fa-dollar-sign', 'fa-credit-card', 'fa-file-alt',
|
|
||||||
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-bullhorn',
|
|
||||||
'fa-flag', 'fa-trophy', 'fa-trophy', 'fa-trophy', 'fa-gift',
|
|
||||||
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud',
|
|
||||||
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
|
|
||||||
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
|
|
||||||
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
|
|
||||||
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
|
|
||||||
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
|
|
||||||
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-sign-in-alt',
|
|
||||||
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
|
|
||||||
]
|
|
||||||
|
|
||||||
const REGULAR_ICONS = [
|
|
||||||
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
|
|
||||||
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
|
|
||||||
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
|
|
||||||
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
|
|
||||||
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
|
|
||||||
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
|
|
||||||
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
|
|
||||||
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
|
|
||||||
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
|
|
||||||
'fa-address-book', 'fa-address-card', 'fa-id-badge',
|
|
||||||
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
|
|
||||||
]
|
|
||||||
|
|
||||||
const BRAND_ICONS = [
|
|
||||||
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
|
|
||||||
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-instagram',
|
|
||||||
'fa-linkedin', 'fa-linkedin-in', 'fa-youtube', 'fa-weixin', 'fa-qq',
|
|
||||||
'fa-weibo', 'fa-telegram', 'fa-telegram-plane', 'fa-discord', 'fa-slack',
|
|
||||||
'fa-whatsapp', 'fa-skype', 'fa-apple', 'fa-windows', 'fa-linux',
|
|
||||||
'fa-android', 'fa-chrome', 'fa-firefox', 'fa-edge', 'fa-safari',
|
|
||||||
'fa-opera', 'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-node', 'fa-react',
|
|
||||||
'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python', 'fa-java', 'fa-rust',
|
|
||||||
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare', 'fa-wordpress',
|
|
||||||
'fa-joomla', 'fa-drupal', 'fa-shopify', 'fa-paypal', 'fa-stripe',
|
|
||||||
'fa-alipay', 'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
|
|
||||||
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn', 'fa-twitch',
|
|
||||||
'fa-spotify', 'fa-soundcloud',
|
|
||||||
]
|
|
||||||
|
|
||||||
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
|
|
||||||
|
|
||||||
const filteredIcons = computed(() => {
|
const filteredIcons = computed(() => {
|
||||||
|
const group = activeGroup.value
|
||||||
|
if (!group || group.type === 'custom') return []
|
||||||
const q = search.value.toLowerCase().replace(/\s+/g, '')
|
const q = search.value.toLowerCase().replace(/\s+/g, '')
|
||||||
const set = iconSets[activePrefix.value] || SOLID_ICONS
|
if (!q) return group.icons
|
||||||
if (!q) return set
|
return group.icons.filter(i => i.includes(q))
|
||||||
return set.filter(i => i.includes(q))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectIcon(icon) {
|
const customPreview = computed(() => cleanSvg(customCode.value))
|
||||||
emit('update:modelValue', `fa ${activePrefix.value} ${icon}`)
|
|
||||||
|
function selectIcon(value) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
popoverShow.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegated on the panel: survives any DOM replacement inside the grids and
|
||||||
|
// works for both the catalog and session grids.
|
||||||
|
function onPanelClick(event) {
|
||||||
|
const item = event.target.closest('[data-icon]')
|
||||||
|
if (item) selectIcon(item.dataset.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCustom() {
|
||||||
|
const svg = cleanSvg(customCode.value)
|
||||||
|
if (!svg) return
|
||||||
|
if (!sessionCustom.value.includes(svg)) sessionCustom.value.push(svg)
|
||||||
|
emit('update:modelValue', `customsvg-${svg}`)
|
||||||
|
customCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.getIconCatalog()
|
||||||
|
if (res?.success && Array.isArray(res.groups) && res.groups.length) {
|
||||||
|
tabs.value = res.groups
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep fallback tabs; the panel stays usable once the endpoint answers.
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-icon-picker__panel {
|
.aksrt-icon-picker__panel {
|
||||||
|
/* The popover teleports to <body>, outside #aksrt-admin-app where the
|
||||||
|
palette is declared — re-declare every token the panel consumes. */
|
||||||
|
--aksrt-color-primary: #18A058;
|
||||||
|
--aksrt-color-primary-hover: #36AD6A;
|
||||||
|
--aksrt-color-primary-soft: rgba(24, 160, 88, 0.12);
|
||||||
|
--aksrt-color-text-secondary: #465149;
|
||||||
|
--aksrt-color-muted: #6F7A72;
|
||||||
|
--aksrt-color-border: #E2E8E3;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,9 +193,35 @@ function selectIcon(icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__tabs {
|
.aksrt-icon-picker__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__tab {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
border: 1px solid var(--aksrt-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--aksrt-color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__tab:hover {
|
||||||
|
border-color: var(--aksrt-color-primary);
|
||||||
|
color: var(--aksrt-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__tab.is-active {
|
||||||
|
border-color: var(--aksrt-color-primary);
|
||||||
|
background: var(--aksrt-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__grid {
|
.aksrt-icon-picker__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
@@ -207,6 +253,18 @@ function selectIcon(icon) {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__item :deep(svg),
|
||||||
|
.aksrt-icon-picker__preview-svg :deep(svg),
|
||||||
|
.aksrt-icon-picker__preview-svg svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__preview-svg {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__empty {
|
.aksrt-icon-picker__empty {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -214,4 +272,27 @@ function selectIcon(icon) {
|
|||||||
color: var(--aksrt-color-muted);
|
color: var(--aksrt-color-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__custom-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__custom-preview {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px dashed var(--aksrt-color-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-picker__grid--session {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--aksrt-color-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<!-- FA class value -->
|
||||||
|
<i v-if="isFaValue(value)" :class="faClass" aria-hidden="true"></i>
|
||||||
|
<!-- Built-in sprite value -->
|
||||||
|
<span
|
||||||
|
v-else-if="isZibsvgValue(value)"
|
||||||
|
class="aksrt-icon-value__svg"
|
||||||
|
v-html="zibsvgMarkup(value)"
|
||||||
|
></span>
|
||||||
|
<!-- Inline custom SVG value -->
|
||||||
|
<span
|
||||||
|
v-else-if="isCustomSvgValue(value)"
|
||||||
|
class="aksrt-icon-value__svg"
|
||||||
|
v-html="cleanSvg(value.slice(10))"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* IconValue — renders any icon-picker value ("fa fas fa-star", "zibsvg-key",
|
||||||
|
* "customsvg-<svg>") as inline preview markup.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { cleanSvg, isFaValue, isZibsvgValue, isCustomSvgValue, zibsvgMarkup } from '@/utils/icons.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const faClass = computed(() => {
|
||||||
|
const raw = String(props.value || '').trim()
|
||||||
|
if (raw.startsWith('fa-')) return `fa fas ${raw}`
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aksrt-icon-value__svg {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-icon-value__svg :deep(svg),
|
||||||
|
.aksrt-icon-value__svg svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
|
||||||
|
*
|
||||||
|
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
|
||||||
|
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
|
||||||
|
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
|
||||||
|
*/
|
||||||
|
export function buildFieldsetSections(schema) {
|
||||||
|
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
|
||||||
|
if (!declared.length) return []
|
||||||
|
|
||||||
|
const byKey = {}
|
||||||
|
const nested = Array.isArray(schema.fields) ? schema.fields : []
|
||||||
|
for (const field of nested) {
|
||||||
|
if (field && field.key) byKey[field.key] = field
|
||||||
|
}
|
||||||
|
|
||||||
|
return declared.map((section) => ({
|
||||||
|
title: String((section && section.title) || ''),
|
||||||
|
fields: (Array.isArray(section && section.keys) ? section.keys : [])
|
||||||
|
.map((key) => byKey[key])
|
||||||
|
.filter(Boolean),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
export const extensionsSection = {
|
export const extensionsSection = {
|
||||||
key: 'extensions',
|
key: 'extensions',
|
||||||
@@ -6,6 +6,16 @@ export const extensionsSection = {
|
|||||||
icon: renderFaIcon('fa fas fa-puzzle-piece'),
|
icon: renderFaIcon('fa fas fa-puzzle-piece'),
|
||||||
route: '/extensions',
|
route: '/extensions',
|
||||||
children: [
|
children: [
|
||||||
|
schemaPage({
|
||||||
|
key: 'extensions-system',
|
||||||
|
name: 'ExtensionsSystem',
|
||||||
|
label: '系统工具',
|
||||||
|
route: '/extensions/system',
|
||||||
|
title: '系统工具',
|
||||||
|
desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
|
||||||
|
canonicalDomain: 'extensions',
|
||||||
|
section: 'system',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'extensions-ip',
|
key: 'extensions-ip',
|
||||||
name: 'ExtensionsIp',
|
name: 'ExtensionsIp',
|
||||||
@@ -32,19 +42,19 @@ export const extensionsSection = {
|
|||||||
label: '百度推送',
|
label: '百度推送',
|
||||||
route: '/extensions/push',
|
route: '/extensions/push',
|
||||||
title: '百度推送',
|
title: '百度推送',
|
||||||
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
|
desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
|
||||||
canonicalDomain: 'extensions',
|
canonicalDomain: 'extensions',
|
||||||
section: 'push',
|
section: 'push',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'extensions-ai',
|
key: 'extensions-security',
|
||||||
name: 'ExtensionAI',
|
name: 'ExtensionsSecurity',
|
||||||
label: 'AI 连接配置',
|
label: '网站安全',
|
||||||
route: '/extensions/ai',
|
route: '/extensions/security',
|
||||||
component: 'ExtensionAI',
|
title: '网站安全',
|
||||||
status: 'available',
|
desc: '防刷限制、验证码发送限制与站点安全加固。',
|
||||||
title: 'AI 连接配置',
|
canonicalDomain: 'extensions',
|
||||||
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
|
section: 'security',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,16 @@ export const globalSection = {
|
|||||||
canonicalDomain: 'global',
|
canonicalDomain: 'global',
|
||||||
section: 'i18n',
|
section: 'i18n',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'global-share',
|
||||||
|
name: 'GlobalShare',
|
||||||
|
label: '社交分享',
|
||||||
|
route: '/global/share',
|
||||||
|
title: '社交分享',
|
||||||
|
desc: '配置分享平台、微信 JSAPI 与海报分享。',
|
||||||
|
canonicalDomain: 'global',
|
||||||
|
section: 'share',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'global-email',
|
key: 'global-email',
|
||||||
name: 'GlobalEmail',
|
name: 'GlobalEmail',
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const SECTION_DEFAULT_CHILDREN = {
|
|||||||
'post-list': 'post-list-page',
|
'post-list': 'post-list-page',
|
||||||
permissions: 'permissions-comment',
|
permissions: 'permissions-comment',
|
||||||
users: 'users-auth',
|
users: 'users-auth',
|
||||||
payment: 'payment-recharge',
|
payment: 'payment-knowledge',
|
||||||
extensions: 'extensions-ip',
|
extensions: 'extensions-system',
|
||||||
tools: 'tools-import-export',
|
tools: 'tools-import-export',
|
||||||
'theme-info': 'theme-info-dashboard',
|
'theme-info': 'theme-info-dashboard',
|
||||||
docs: 'docs-dashboard',
|
docs: 'docs-dashboard',
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付&付费 — 子菜单按参考主题划分:
|
||||||
|
* 知识付费 / 余额充值 / VIP会员 / 创作分成 / 推广返佣 / 收款接口 / 提现策略。
|
||||||
|
* 收款接口为 schema 表单页(双下拉式接口选择 + 凭据配置 + 前缀配置)。
|
||||||
|
*/
|
||||||
export const paymentSection = {
|
export const paymentSection = {
|
||||||
key: 'payment',
|
key: 'payment',
|
||||||
label: '支付&付费',
|
label: '支付&付费',
|
||||||
icon: renderFaIcon('fa fas fa-credit-card'),
|
icon: renderFaIcon('fa fas fa-credit-card'),
|
||||||
route: '/payment',
|
route: '/payment',
|
||||||
children: [
|
children: [
|
||||||
|
schemaPage({
|
||||||
|
key: 'payment-knowledge',
|
||||||
|
name: 'PaymentKnowledge',
|
||||||
|
label: '知识付费',
|
||||||
|
route: '/payment/knowledge',
|
||||||
|
status: 'available',
|
||||||
|
title: '知识付费',
|
||||||
|
desc: '免登陆购买、模块显示位置、商品优惠码、客户服务、付费下载与商品预置参数。',
|
||||||
|
canonicalDomain: 'payment',
|
||||||
|
section: 'knowledge',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-recharge',
|
key: 'payment-recharge',
|
||||||
name: 'PaymentRecharge',
|
name: 'PaymentRecharge',
|
||||||
@@ -13,7 +29,7 @@ export const paymentSection = {
|
|||||||
route: '/payment/recharge',
|
route: '/payment/recharge',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '余额充值',
|
title: '余额充值',
|
||||||
desc: '余额充值策略、记录和手动确认配置。',
|
desc: '余额功能、充值商品选项、卡密充值、充值卡与余额兑换积分。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'recharge',
|
section: 'recharge',
|
||||||
}),
|
}),
|
||||||
@@ -24,41 +40,18 @@ export const paymentSection = {
|
|||||||
route: '/payment/vip',
|
route: '/payment/vip',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: 'VIP会员',
|
title: 'VIP会员',
|
||||||
desc: '会员套餐、会员特权和免扣策略。',
|
desc: '会员等级、会员权益、购买选项、卡密兑换与积分兑换。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'vip',
|
section: 'vip',
|
||||||
}),
|
}),
|
||||||
customPage({
|
|
||||||
key: 'payment-gateway',
|
|
||||||
name: 'PaymentGateway',
|
|
||||||
label: '收款接口',
|
|
||||||
route: '/payment/gateway',
|
|
||||||
component: 'PaymentGateway',
|
|
||||||
status: 'available',
|
|
||||||
title: '收款接口',
|
|
||||||
desc: '支付网关适配器、测试网关和手动确认配置。',
|
|
||||||
canonicalDomain: 'payment',
|
|
||||||
section: 'gateway',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
|
||||||
key: 'payment-order',
|
|
||||||
name: 'PaymentOrder',
|
|
||||||
label: '订单设置',
|
|
||||||
route: '/payment/order',
|
|
||||||
status: 'available',
|
|
||||||
title: '订单设置',
|
|
||||||
desc: '订单前缀、有效期、展示字段和状态文案。',
|
|
||||||
canonicalDomain: 'payment',
|
|
||||||
section: 'order',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-revenue',
|
key: 'payment-revenue',
|
||||||
name: 'PaymentRevenue',
|
name: 'PaymentRevenue',
|
||||||
label: '创作中心',
|
label: '创作分成',
|
||||||
route: '/payment/revenue',
|
route: '/payment/revenue',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '创作中心',
|
title: '创作分成',
|
||||||
desc: '独立配置现金与积分分成比例、结算周期和结算备注文案。',
|
desc: '现金与积分分成比例、结算周期、分成说明与详情页(分成工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'revenue',
|
section: 'revenue',
|
||||||
}),
|
}),
|
||||||
@@ -69,20 +62,20 @@ export const paymentSection = {
|
|||||||
route: '/payment/affiliate',
|
route: '/payment/affiliate',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '推广返佣',
|
title: '推广返佣',
|
||||||
desc: '返佣规则、邀请链路和结算策略(返佣工单在支付中心)。',
|
desc: '推广识别模式、分档返佣规则、文案与海报(返佣工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'affiliate',
|
section: 'affiliate',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-card-pass',
|
key: 'payment-gateway',
|
||||||
name: 'PaymentCardPass',
|
name: 'PaymentGateway',
|
||||||
label: '卡密设置',
|
label: '收款接口',
|
||||||
route: '/payment/card-pass',
|
route: '/payment/gateway',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '卡密设置',
|
title: '收款接口',
|
||||||
desc: '充值卡类型、卡号规则与兑换文案(卡密工单在支付中心)。',
|
desc: '微信/支付宝接口选择、PayPal/Stripe 开关、货币结算、显示顺序与订单支付时效。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'card-pass',
|
section: 'gateway',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-withdrawal',
|
key: 'payment-withdrawal',
|
||||||
@@ -91,9 +84,20 @@ export const paymentSection = {
|
|||||||
route: '/payment/withdrawal',
|
route: '/payment/withdrawal',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '提现策略',
|
title: '提现策略',
|
||||||
desc: '最低提现、手续费与说明文案(提现工单在支付中心)。',
|
desc: '提现门槛、手续费、每日限制、收款渠道与提现协议(提现工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'withdrawal',
|
section: 'withdrawal',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'payment-after-sale',
|
||||||
|
name: 'PaymentAfterSale',
|
||||||
|
label: '售后策略',
|
||||||
|
route: '/payment/after-sale',
|
||||||
|
status: 'available',
|
||||||
|
title: '售后策略',
|
||||||
|
desc: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、所需材料、图片上传)配置。',
|
||||||
|
canonicalDomain: 'payment',
|
||||||
|
section: 'after_sale',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -37,26 +37,15 @@ export const permissionsSection = {
|
|||||||
canonicalDomain: 'permissions',
|
canonicalDomain: 'permissions',
|
||||||
section: 'moderation',
|
section: 'moderation',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'permissions-attachment',
|
key: 'permissions-attachment',
|
||||||
name: 'PermissionsAttachment',
|
name: 'PermissionsAttachment',
|
||||||
label: '附件配置',
|
label: '附件配置',
|
||||||
route: '/permissions/attachment',
|
route: '/permissions/attachment',
|
||||||
component: 'AttachmentPermissions',
|
|
||||||
title: '附件配置',
|
title: '附件配置',
|
||||||
desc: '管理文件大小、类型、安全限制和审核策略。',
|
desc: '头像图片、上传大小、上传格式与上传行为配置。',
|
||||||
canonicalDomain: 'permissions',
|
canonicalDomain: 'permissions',
|
||||||
section: 'attachment',
|
section: 'attachment',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'permissions-download',
|
|
||||||
name: 'PermissionsDownload',
|
|
||||||
label: '下载配置',
|
|
||||||
route: '/permissions/download',
|
|
||||||
title: '下载配置',
|
|
||||||
desc: '下载链接别名、默认使用次数与有效期限规则。',
|
|
||||||
canonicalDomain: 'permissions',
|
|
||||||
section: 'download',
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -57,16 +57,6 @@ export const postListSection = {
|
|||||||
canonicalDomain: 'post_list',
|
canonicalDomain: 'post_list',
|
||||||
section: 'feature',
|
section: 'feature',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'post-list-share',
|
|
||||||
name: 'PostListShare',
|
|
||||||
label: '分享设置',
|
|
||||||
route: '/post-list/share',
|
|
||||||
title: '分享设置',
|
|
||||||
desc: '配置微信分享凭据、海报分享和社交分享选项。',
|
|
||||||
canonicalDomain: 'post_list',
|
|
||||||
section: 'share',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'post-list-comment',
|
key: 'post-list-comment',
|
||||||
name: 'PostListComment',
|
name: 'PostListComment',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Users SPA keeps config-shaped pages.
|
* Users SPA keeps config-shaped pages.
|
||||||
* Message / referral-code / ban / report queues live under users.php native menus.
|
* Message / referral-code queues live under users.php native menus;
|
||||||
|
* 禁封/举报 is a config page (schema section "report"); ticket queues
|
||||||
|
* (举报&禁封申诉) stay on the native WP users.php page.
|
||||||
*/
|
*/
|
||||||
export const usersSection = {
|
export const usersSection = {
|
||||||
key: 'users',
|
key: 'users',
|
||||||
@@ -58,7 +60,7 @@ export const usersSection = {
|
|||||||
route: '/users/verify',
|
route: '/users/verify',
|
||||||
component: 'UsersVerify',
|
component: 'UsersVerify',
|
||||||
title: '身份认证',
|
title: '身份认证',
|
||||||
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。',
|
desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
|
||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'verify',
|
section: 'verify',
|
||||||
}),
|
}),
|
||||||
@@ -106,16 +108,6 @@ export const usersSection = {
|
|||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'sms',
|
section: 'sms',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'users-wechat',
|
|
||||||
name: 'UsersWechat',
|
|
||||||
label: '微信通知',
|
|
||||||
route: '/users/wechat',
|
|
||||||
title: '微信通知',
|
|
||||||
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
|
|
||||||
canonicalDomain: 'users',
|
|
||||||
section: 'wechat',
|
|
||||||
}),
|
|
||||||
customPage({
|
customPage({
|
||||||
key: 'users-wechat-template',
|
key: 'users-wechat-template',
|
||||||
name: 'UsersWechatTemplate',
|
name: 'UsersWechatTemplate',
|
||||||
@@ -128,5 +120,15 @@ export const usersSection = {
|
|||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'wechat-template',
|
section: 'wechat-template',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'users-ban',
|
||||||
|
name: 'UsersBan',
|
||||||
|
label: '禁封/举报',
|
||||||
|
route: '/users/ban',
|
||||||
|
title: '禁封/举报',
|
||||||
|
desc: '禁封功能、举报功能、预置原因、申诉资料与举报说明配置;举报与申诉工单在 WordPress 用户菜单处理。',
|
||||||
|
canonicalDomain: 'users',
|
||||||
|
section: 'report',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js'
|
|
||||||
|
|
||||||
function cloneDefaults(defaults = {}) {
|
|
||||||
return JSON.parse(JSON.stringify(defaults))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSectionDefaults(domain, section) {
|
|
||||||
const domainKey = normalizeSchemaDomain(domain)
|
|
||||||
const sectionSchema = ALL_SCHEMAS[domainKey]?.[section]
|
|
||||||
return cloneDefaults(sectionSchema?.defaults || {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDomainDefaults(domain) {
|
|
||||||
const domainKey = normalizeSchemaDomain(domain)
|
|
||||||
const sections = Object.values(ALL_SCHEMAS[domainKey] || {})
|
|
||||||
|
|
||||||
return sections.reduce((defaults, schema) => {
|
|
||||||
return {
|
|
||||||
...defaults,
|
|
||||||
...cloneDefaults(schema?.defaults || {}),
|
|
||||||
}
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => {
|
|
||||||
defaults[domain] = getDomainDefaults(domain)
|
|
||||||
return defaults
|
|
||||||
}, {})
|
|
||||||
@@ -23,15 +23,13 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card title="统一资产账本" class="aksrt-section-card">
|
<n-card title="用户近期资产记录" class="aksrt-section-card">
|
||||||
<n-space vertical :size="12">
|
<n-space vertical :size="12">
|
||||||
<n-space wrap>
|
<n-space wrap>
|
||||||
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" />
|
<n-input v-model:value="ledgerFilters.user_id" placeholder="用户 ID" clearable style="width: 130px" />
|
||||||
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" />
|
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" placeholder="资产" style="width: 130px" />
|
||||||
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" />
|
<n-input v-model:value="ledgerFilters.date_from" type="date" placeholder="开始日期" style="width: 160px" />
|
||||||
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" />
|
<n-input v-model:value="ledgerFilters.date_to" type="date" placeholder="结束日期" style="width: 160px" />
|
||||||
<n-input v-model:value="ledgerFilters.ref_key" placeholder="引用键" clearable style="width: 180px" />
|
|
||||||
<n-input v-model:value="ledgerFilters.actor_user_id" placeholder="操作者 ID" clearable style="width: 130px" />
|
|
||||||
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
|
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
|
||||||
<n-button @click="resetLedger">重置</n-button>
|
<n-button @click="resetLedger">重置</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
@@ -40,13 +38,13 @@
|
|||||||
:data="ledgerRows"
|
:data="ledgerRows"
|
||||||
:loading="ledgerLoading"
|
:loading="ledgerLoading"
|
||||||
:row-key="row => row.id"
|
:row-key="row => row.id"
|
||||||
:scroll-x="1600"
|
:scroll-x="1100"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
v-model:page="ledgerPage"
|
v-model:page="ledgerPage"
|
||||||
:page-count="ledgerPageCount"
|
:page-count="ledgerPageCount"
|
||||||
:page-size="20"
|
:page-size="10"
|
||||||
@update:page="loadLedger"
|
@update:page="loadLedger"
|
||||||
/>
|
/>
|
||||||
</n-space>
|
</n-space>
|
||||||
@@ -169,40 +167,41 @@ const ledgerRows = ref([])
|
|||||||
const ledgerPage = ref(1)
|
const ledgerPage = ref(1)
|
||||||
const ledgerPageCount = ref(1)
|
const ledgerPageCount = ref(1)
|
||||||
const ledgerFilters = reactive({
|
const ledgerFilters = reactive({
|
||||||
asset: null,
|
user_id: '',
|
||||||
transaction_id: '',
|
asset: 'points',
|
||||||
operation_key: '',
|
date_from: '',
|
||||||
ref_type: '',
|
date_to: '',
|
||||||
ref_key: '',
|
|
||||||
actor_user_id: '',
|
|
||||||
})
|
})
|
||||||
const assetOptions = [
|
const assetOptions = [
|
||||||
{ label: '积分', value: 'points' },
|
{ label: '积分', value: 'points' },
|
||||||
{ label: '余额', value: 'balance' },
|
{ label: '余额', value: 'balance' },
|
||||||
]
|
]
|
||||||
const ledgerColumns = [
|
const ledgerColumns = [
|
||||||
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' },
|
{ title: '类型', key: 'reason', width: 150 },
|
||||||
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } },
|
{ title: '订单号', key: 'ref_key', width: 200 },
|
||||||
{ title: '资产', key: 'assets_label', width: 100 },
|
{ title: '变动 / 变动后', key: 'delta_label', width: 190 },
|
||||||
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } },
|
{ title: '说明', key: 'remark', width: 240, ellipsis: { tooltip: true } },
|
||||||
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } },
|
{ title: '时间', key: 'created_at', width: 170 },
|
||||||
{ title: '操作者', key: 'actor_label', width: 100 },
|
|
||||||
{ title: '增减 / 手续费', key: 'delta_label', width: 180, ellipsis: { tooltip: true } },
|
|
||||||
{ title: '变动后余额', key: 'balance_label', width: 150, ellipsis: { tooltip: true } },
|
|
||||||
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
|
|
||||||
{ title: 'UTC 时间', key: 'created_at', width: 190 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatAssetValue(asset, value) {
|
function formatAssetValue(asset, value) {
|
||||||
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00'
|
const raw = Number(value ?? 0)
|
||||||
const raw = String(value ?? '0')
|
if (!Number.isFinite(raw)) return asset === 'balance' ? '0.00' : '0'
|
||||||
return /^-?\d+$/.test(raw) ? raw : '0'
|
if (asset !== 'balance') return String(raw)
|
||||||
|
const negative = raw < 0
|
||||||
|
const digits = String(Math.abs(Math.trunc(raw))).padStart(3, '0')
|
||||||
|
const whole = digits.slice(0, -2).replace(/^0+(?=\d)/, '')
|
||||||
|
return (negative ? '-' : '') + whole + '.' + digits.slice(-2)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLedger() {
|
async function loadLedger() {
|
||||||
ledgerLoading.value = true
|
ledgerLoading.value = true
|
||||||
try {
|
try {
|
||||||
const params = { page: ledgerPage.value, per_page: 20 }
|
if (!/^\d+$/.test(String(ledgerFilters.user_id).trim())) {
|
||||||
|
message.warning('请输入用户 ID 后查询该用户近期记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = { page: ledgerPage.value, per_page: 10 }
|
||||||
Object.entries(ledgerFilters).forEach(([key, value]) => {
|
Object.entries(ledgerFilters).forEach(([key, value]) => {
|
||||||
if (value !== null && String(value).trim() !== '') params[key] = value
|
if (value !== null && String(value).trim() !== '') params[key] = value
|
||||||
})
|
})
|
||||||
@@ -210,23 +209,16 @@ async function loadLedger() {
|
|||||||
const data = response?.data || response || {}
|
const data = response?.data || response || {}
|
||||||
ledgerRows.value = (data.items || []).map((row) => {
|
ledgerRows.value = (data.items || []).map((row) => {
|
||||||
const entries = Array.isArray(row.entries) ? row.entries : []
|
const entries = Array.isArray(row.entries) ? row.entries : []
|
||||||
|
const entry = entries[0] || {}
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
transaction_label: `#${row.id}`,
|
delta_label: `${formatAssetValue(entry.asset, entry.delta)} / ${formatAssetValue(entry.asset, entry.balance_after)}`,
|
||||||
assets_label: (row.assets || []).join(' / '),
|
remark: row.remark || '—',
|
||||||
reference_label: [row.ref_type, row.ref_key].filter(Boolean).join(':') || '—',
|
|
||||||
actor_label: row.actor_user_id ? `#${row.actor_user_id}` : '系统',
|
|
||||||
delta_label: entries.map((entry) => {
|
|
||||||
const value = formatAssetValue(entry.asset, entry.delta)
|
|
||||||
const signed = entry.asset === 'points' && !value.startsWith('-') && value !== '0' ? `+${value}` : value
|
|
||||||
return `${entry.asset}:${signed}`
|
|
||||||
}).join(';'),
|
|
||||||
balance_label: entries.map((entry) => `${entry.asset}:${formatAssetValue(entry.asset, entry.balance_after)}`).join(';'),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20)))
|
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 10)))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e.message || '加载账本失败')
|
message.error(e.message || '加载资产记录失败')
|
||||||
} finally {
|
} finally {
|
||||||
ledgerLoading.value = false
|
ledgerLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -238,8 +230,10 @@ function searchLedger() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetLedger() {
|
function resetLedger() {
|
||||||
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' })
|
Object.assign(ledgerFilters, { user_id: '', asset: 'points', date_from: '', date_to: '' })
|
||||||
searchLedger()
|
ledgerRows.value = []
|
||||||
|
ledgerPage.value = 1
|
||||||
|
ledgerPageCount.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCfg() {
|
async function loadCfg() {
|
||||||
@@ -270,6 +264,5 @@ async function saveCfg() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCfg()
|
loadCfg()
|
||||||
loadLedger()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
:icon="pageMeta.icon"
|
:icon="pageMeta.icon"
|
||||||
:schema="pageMeta.groups"
|
:schema="pageMeta.groups"
|
||||||
:form-data="formData"
|
:form-data="formData"
|
||||||
:dirty="dirty"
|
:dirty="isSmsPage ? isSmsSettingsDirty : dirty"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:load-error="loadError"
|
:load-error="loadError"
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
@update:form-data="formData = $event"
|
@update:form-data="formData = $event"
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
@reload="handleReload"
|
@reload="handleReload"
|
||||||
|
@field-action="handleFieldAction"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-if="isPermissionsLogin"
|
v-if="isPermissionsLogin"
|
||||||
@@ -130,6 +131,7 @@ const showTestModal = ref(false)
|
|||||||
const testLoading = ref(false)
|
const testLoading = ref(false)
|
||||||
const testResult = ref(null)
|
const testResult = ref(null)
|
||||||
const serverSchema = ref(null)
|
const serverSchema = ref(null)
|
||||||
|
const smsSavedSettings = ref('')
|
||||||
const captchaSecretsToClear = ref(new Set())
|
const captchaSecretsToClear = ref(new Set())
|
||||||
const captchaProviders = [
|
const captchaProviders = [
|
||||||
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
|
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
|
||||||
@@ -142,8 +144,18 @@ const captchaProviderTab = ref('tencent')
|
|||||||
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
||||||
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
||||||
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
||||||
|
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
|
||||||
|
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
|
||||||
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
||||||
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
||||||
|
const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value))
|
||||||
|
|
||||||
|
function smsSettingsSnapshot(data) {
|
||||||
|
const settings = { ...(data || {}) }
|
||||||
|
delete settings.sms_test_phone
|
||||||
|
delete settings.sms_test_send
|
||||||
|
return JSON.stringify(settings)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDomainFromPath(path) {
|
function resolveDomainFromPath(path) {
|
||||||
const parts = path.split('/').filter(Boolean)
|
const parts = path.split('/').filter(Boolean)
|
||||||
@@ -158,7 +170,7 @@ function resolveSectionFromPath(path) {
|
|||||||
|
|
||||||
/** Load schema from the Vue-side registry */
|
/** Load schema from the Vue-side registry */
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
const schema = resolvedDomain.value === 'page_display'
|
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||||
? serverSchema.value
|
? serverSchema.value
|
||||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||||
if (schema && schema.groups && schema.groups.length > 0) {
|
if (schema && schema.groups && schema.groups.length > 0) {
|
||||||
@@ -180,7 +192,7 @@ const pageMeta = computed(() => {
|
|||||||
|
|
||||||
/** Default values from schema */
|
/** Default values from schema */
|
||||||
const defaults = computed(() => {
|
const defaults = computed(() => {
|
||||||
const schema = resolvedDomain.value === 'page_display'
|
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||||
? serverSchema.value
|
? serverSchema.value
|
||||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||||
if (!schema) return {}
|
if (!schema) return {}
|
||||||
@@ -202,9 +214,7 @@ const {
|
|||||||
} = useSectionForm({
|
} = useSectionForm({
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
|
const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
|
||||||
if (resolvedDomain.value === 'page_display') {
|
serverSchema.value = response?.schema || serverSchema.value
|
||||||
serverSchema.value = response?.schema || null
|
|
||||||
}
|
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
submit: async (data) => {
|
submit: async (data) => {
|
||||||
@@ -216,6 +226,10 @@ const {
|
|||||||
delete payload[key]
|
delete payload[key]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (isSmsPage.value) {
|
||||||
|
delete payload.sms_test_phone
|
||||||
|
delete payload.sms_test_send
|
||||||
|
}
|
||||||
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
|
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
|
||||||
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
|
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
|
||||||
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
|
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
|
||||||
@@ -226,11 +240,52 @@ const {
|
|||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
defaults: defaults.value,
|
defaults: defaults.value,
|
||||||
|
onLoaded: (data) => {
|
||||||
|
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
|
||||||
|
},
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
|
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
|
||||||
message.success('设置已保存')
|
message.success('设置已保存')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleFieldAction(key) {
|
||||||
|
if (key === 'sms_test_send' && isSmsPage.value) {
|
||||||
|
const phone = String(formData.value?.sms_test_phone || '').trim()
|
||||||
|
if (!phone) {
|
||||||
|
message.warning('请先填写测试手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSmsSettingsDirty.value) {
|
||||||
|
message.warning('请先保存当前配置,再发送测试短信')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.testSms(phone)
|
||||||
|
message.success(response?.message || '测试短信已发送')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '测试短信发送失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key !== 'email_test_send' || !isEmailPage.value) return
|
||||||
|
const to = String(formData.value?.email_test_to || '').trim()
|
||||||
|
if (!to) {
|
||||||
|
message.warning('请先填写测试收件地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dirty.value) {
|
||||||
|
message.warning('请先保存当前配置,再发送测试邮件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.testMail(to)
|
||||||
|
message.success(response?.message || '测试邮件已发送')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '测试邮件发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
await doSave()
|
await doSave()
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -75,7 +75,7 @@
|
|||||||
<i class="fa fas fa-bars"></i>
|
<i class="fa fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="tabbar-sort__icon">
|
<span class="tabbar-sort__icon">
|
||||||
<i v-if="element.icon" :class="element.icon"></i>
|
<IconValue v-if="element.icon" :value="element.icon" />
|
||||||
</span>
|
</span>
|
||||||
<div class="tabbar-sort__meta">
|
<div class="tabbar-sort__meta">
|
||||||
<div class="tabbar-sort__title-row">
|
<div class="tabbar-sort__title-row">
|
||||||
@@ -128,16 +128,13 @@
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="图标 (Font Awesome)">
|
<n-form-item label="图标 (Font Awesome / 内置 SVG)">
|
||||||
<n-input v-model:value="editingItem.icon" placeholder="例如: fa fas fa-home" />
|
<FaIconPicker v-model="editingItem.icon" placeholder="选择图标" />
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<div class="icon-preview-row">
|
<div class="icon-preview-row">
|
||||||
<span>预览:</span>
|
<span>预览:</span>
|
||||||
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
|
<IconValue v-if="editingItem.icon" :value="editingItem.icon" style="font-size: 20px;" />
|
||||||
<span v-else class="muted-text">无图标</span>
|
<span v-else class="muted-text">无图标</span>
|
||||||
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
|
|
||||||
<i class="fa fas fa-external-link-alt"></i> Font Awesome 图标库
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -230,7 +227,7 @@
|
|||||||
<i class="fa fas fa-bars"></i>
|
<i class="fa fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
||||||
<n-input v-model:value="element.icon" placeholder="图标,例如:fa fas fa-pen" />
|
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
|
||||||
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
||||||
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
|
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
|
||||||
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||||
@@ -268,6 +265,8 @@ import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
|||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import IconValue from '@/components/form/IconValue.vue'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
|
|
||||||
|
|
||||||
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
|
|
||||||
<strong>加载失败:</strong>{{ loadError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__status">
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
|
||||||
<i class="fa fab fa-android"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">可用状态</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
|
||||||
<i class="fa fas fa-plug"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">WP AI Client</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.theme_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
|
||||||
<i class="fa fas fa-key"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">主题 Provider</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
|
||||||
<i class="fa fas fa-road"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">当前后端</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card">
|
|
||||||
<h3>主题 Provider 配置(回退链路)</h3>
|
|
||||||
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
|
|
||||||
<div class="aksrt-form-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group">
|
|
||||||
<label>AI Provider</label>
|
|
||||||
<select v-model="form.provider" class="aksrt-select">
|
|
||||||
<option value="none">不使用</option>
|
|
||||||
<option value="openai">OpenAI 兼容接口</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>API Key</label>
|
|
||||||
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
|
|
||||||
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
|
|
||||||
<label>API Base URL</label>
|
|
||||||
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://llm.example.com/v1">
|
|
||||||
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</p>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>模型</label>
|
|
||||||
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>最大 Token 数</label>
|
|
||||||
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>每分钟请求限制</label>
|
|
||||||
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
|
|
||||||
{{ saving ? '保存中...' : '保存设置' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card">
|
|
||||||
<h3>已接通能力</h3>
|
|
||||||
<table class="aksrt-table aksrt-table--sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>能力</th>
|
|
||||||
<th>入口</th>
|
|
||||||
<th>状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="feat in features" :key="feat.key">
|
|
||||||
<td>{{ feat.name }}</td>
|
|
||||||
<td>{{ feat.scene }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
|
||||||
{{ feat.ready ? '可用' : '未就绪' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card aksrt-ai__notice">
|
|
||||||
<h3><i class="fa fas fa-exclamation-triangle"></i> 重要说明</h3>
|
|
||||||
<ul>
|
|
||||||
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
|
|
||||||
<li>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
|
|
||||||
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
|
||||||
import PageHeader from '@/components/PageHeader.vue';
|
|
||||||
import api from '@/api';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ExtensionAI',
|
|
||||||
components: { PageHeader },
|
|
||||||
setup() {
|
|
||||||
const status = ref({
|
|
||||||
enabled: false,
|
|
||||||
provider: 'none',
|
|
||||||
configured: false,
|
|
||||||
model: '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: 10,
|
|
||||||
available: false,
|
|
||||||
backend: 'none',
|
|
||||||
wp_ai_available: false,
|
|
||||||
theme_available: false,
|
|
||||||
seo_ai_enabled: false,
|
|
||||||
write_assist: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
enabled: false,
|
|
||||||
provider: 'none',
|
|
||||||
api_key: '',
|
|
||||||
api_base: 'https://api.openai.com/v1',
|
|
||||||
model: 'gpt-3.5-turbo',
|
|
||||||
max_tokens: 1000,
|
|
||||||
rate_limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saving = ref(false);
|
|
||||||
const loadError = ref('');
|
|
||||||
|
|
||||||
const backendLabel = computed(() => {
|
|
||||||
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
|
|
||||||
return map[status.value.backend] || status.value.backend || '无';
|
|
||||||
});
|
|
||||||
|
|
||||||
const features = computed(() => {
|
|
||||||
const avail = !!status.value.available;
|
|
||||||
const seoReady = avail && !!status.value.seo_ai_enabled;
|
|
||||||
return [
|
|
||||||
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
|
|
||||||
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
|
|
||||||
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
function hydrateFromStatus(data) {
|
|
||||||
if (!data || typeof data !== 'object') return;
|
|
||||||
status.value = {
|
|
||||||
enabled: !!data.enabled,
|
|
||||||
provider: data.provider || 'none',
|
|
||||||
configured: !!data.configured,
|
|
||||||
model: data.model || '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: data.rate_limit || 10,
|
|
||||||
available: !!data.available,
|
|
||||||
backend: data.backend || 'none',
|
|
||||||
wp_ai_available: !!data.wp_ai_available,
|
|
||||||
theme_available: !!data.theme_available,
|
|
||||||
seo_ai_enabled: !!data.seo_ai_enabled,
|
|
||||||
write_assist: !!data.write_assist,
|
|
||||||
};
|
|
||||||
form.enabled = !!data.enabled;
|
|
||||||
form.provider = data.provider || 'none';
|
|
||||||
form.model = data.model || 'gpt-3.5-turbo';
|
|
||||||
form.rate_limit = data.rate_limit || 10;
|
|
||||||
if (data.api_base) form.api_base = data.api_base;
|
|
||||||
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
|
|
||||||
form.max_tokens = data.max_tokens;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
loadError.value = '';
|
|
||||||
try {
|
|
||||||
const json = await api.getAiStatus();
|
|
||||||
hydrateFromStatus(json.data || {});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Load AI status failed:', e);
|
|
||||||
loadError.value = e?.message || '加载 AI 状态失败';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
enabled: form.enabled,
|
|
||||||
provider: form.provider,
|
|
||||||
api_base: form.api_base,
|
|
||||||
model: form.model,
|
|
||||||
max_tokens: form.max_tokens,
|
|
||||||
rate_limit: form.rate_limit,
|
|
||||||
};
|
|
||||||
// Only send api_key when the user entered a new one.
|
|
||||||
if (form.api_key && form.api_key !== '***') {
|
|
||||||
payload.api_key = form.api_key;
|
|
||||||
}
|
|
||||||
const json = await api.updateAiSettings(payload);
|
|
||||||
alert(json.message || '设置已保存');
|
|
||||||
await loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Save failed:', e);
|
|
||||||
alert(e?.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => loadStatus());
|
|
||||||
|
|
||||||
return { status, form, saving, loadError, features, backendLabel, saveSettings };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-ai__status {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-icon--ok { background: #d4edda; color: #155724; }
|
|
||||||
.aksrt-ai__status-icon--warn { background: #fff3cd; color: #856404; }
|
|
||||||
.aksrt-ai__status-icon--off { background: #f8d7da; color: #721c24; }
|
|
||||||
.aksrt-ai__status-icon--info { background: #d1ecf1; color: #0c5460; }
|
|
||||||
.aksrt-ai__status-value { font-size: 18px; font-weight: 600; color: var(--aksrt-color-text); }
|
|
||||||
.aksrt-ai__status-label { font-size: 12px; color: #888; }
|
|
||||||
.aksrt-ai__form-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__form-card h3 { margin: 0 0 16px; font-size: 16px; font-weight: 600; }
|
|
||||||
.aksrt-form-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
|
||||||
.aksrt-ai__load-error {
|
|
||||||
background: #fdecea;
|
|
||||||
border-color: #f5c2c0;
|
|
||||||
color: #842029;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__notice { background: #fff8e1; border-color: #ffe082; }
|
|
||||||
.aksrt-ai__notice h3 { color: #e65100; }
|
|
||||||
.aksrt-ai__notice ul { margin: 0; padding-left: 20px; }
|
|
||||||
.aksrt-ai__notice li { margin-bottom: 8px; font-size: 14px; }
|
|
||||||
.aksrt-ai__notice code {
|
|
||||||
background: rgba(0,0,0,0.06);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
|
||||||
.aksrt-badge--success { background: #d4edda; color: #155724; }
|
|
||||||
.aksrt-badge--warning { background: #fff3cd; color: #856404; }
|
|
||||||
.aksrt-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
|
|
||||||
.aksrt-form-group { margin-bottom: 14px; }
|
|
||||||
.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; }
|
|
||||||
.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; }
|
|
||||||
.aksrt-button--primary { background: #1677ff; color: #fff; }
|
|
||||||
.aksrt-button:disabled { opacity: .6; cursor: not-allowed; }
|
|
||||||
</style>
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
|
|
||||||
|
|
||||||
<div class="aksrt-charge-card-page__stats">
|
|
||||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
|
||||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-card size="small" title="操作台">
|
|
||||||
<n-space justify="space-between" wrap>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
|
|
||||||
<n-button @click="batchVisible = true">批量生成</n-button>
|
|
||||||
</n-space>
|
|
||||||
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
|
||||||
<n-button @click="resetFilters">重置</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="rows"
|
|
||||||
:loading="loading"
|
|
||||||
:bordered="false"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
|
|
||||||
<n-form :model="createForm" label-placement="top">
|
|
||||||
<n-form-item label="卡类型">
|
|
||||||
<n-select v-model:value="createForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="卡密">
|
|
||||||
<n-input v-model:value="createForm.password" placeholder="手动填写唯一卡密" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="createForm.type === 'balance'" label="面额">
|
|
||||||
<n-input v-model:value="createForm.amount" inputmode="decimal" placeholder="例如 10.00" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="createForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-form-item v-if="createForm.type === 'credits'" label="积分">
|
|
||||||
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="过期时间">
|
|
||||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="备注">
|
|
||||||
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-space justify="end">
|
|
||||||
<n-button @click="createVisible = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
|
|
||||||
<n-form :model="batchForm" label-placement="top">
|
|
||||||
<n-form-item label="卡类型">
|
|
||||||
<n-select v-model:value="batchForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-grid cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="数量">
|
|
||||||
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item v-if="batchForm.type === 'balance'">
|
|
||||||
<n-form-item label="面额">
|
|
||||||
<n-input v-model:value="batchForm.amount" inputmode="decimal" placeholder="例如 10.00" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item v-if="batchForm.type === 'credits'">
|
|
||||||
<n-form-item label="积分">
|
|
||||||
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="batchForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-grid cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="前缀">
|
|
||||||
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="随机长度">
|
|
||||||
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-form-item label="过期时间">
|
|
||||||
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="备注">
|
|
||||||
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-space justify="end">
|
|
||||||
<n-button @click="batchVisible = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const loading = ref(false)
|
|
||||||
const rows = ref([])
|
|
||||||
const createVisible = ref(false)
|
|
||||||
const batchVisible = ref(false)
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const currentCardId = ref(null)
|
|
||||||
const submittingCreate = ref(false)
|
|
||||||
const submittingBatch = ref(false)
|
|
||||||
|
|
||||||
const stats = reactive({
|
|
||||||
total: 0,
|
|
||||||
active: 0,
|
|
||||||
used: 0,
|
|
||||||
total_amount: 0,
|
|
||||||
used_amount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
keyword: '',
|
|
||||||
status: '',
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createForm = reactive({
|
|
||||||
type: 'balance',
|
|
||||||
password: '',
|
|
||||||
amount: '10.00',
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const batchForm = reactive({
|
|
||||||
type: 'balance',
|
|
||||||
count: 20,
|
|
||||||
amount: '10.00',
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
prefix: 'AK',
|
|
||||||
length: 12,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeOptions = [
|
|
||||||
{ label: '余额卡', value: 'balance' },
|
|
||||||
{ label: '会员卡', value: 'vip' },
|
|
||||||
{ label: '积分卡', value: 'credits' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const levelOptions = [
|
|
||||||
{ label: 'VIP1', value: 1 },
|
|
||||||
{ label: 'VIP2', value: 2 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const pager = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
itemCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optionsState = reactive({
|
|
||||||
statuses: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ key: 'total', label: '总卡数' },
|
|
||||||
{ key: 'active', label: '未使用' },
|
|
||||||
{ key: 'used', label: '已使用' },
|
|
||||||
{ key: 'total_amount', label: '总面额' },
|
|
||||||
{ key: 'used_amount', label: '已兑换金额' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions = computed(() => optionsState.statuses.length
|
|
||||||
? optionsState.statuses
|
|
||||||
: [
|
|
||||||
{ label: '全部状态', value: '' },
|
|
||||||
{ label: '未使用', value: 'active' },
|
|
||||||
{ label: '已使用', value: 'used' },
|
|
||||||
{ label: '已过期', value: 'expired' },
|
|
||||||
{ label: '已禁用', value: 'disabled' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
|
||||||
page: pager.page,
|
|
||||||
pageSize: pager.pageSize,
|
|
||||||
itemCount: pager.itemCount,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 20, 50, 100],
|
|
||||||
onUpdatePage: (page) => {
|
|
||||||
pager.page = page
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
onUpdatePageSize: (pageSize) => {
|
|
||||||
pager.pageSize = pageSize
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
|
||||||
{ title: '卡密', key: 'password', minWidth: 170, ellipsis: { tooltip: true } },
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type_label',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => row.type_label || row.type || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '面额/权益',
|
|
||||||
key: 'amount',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => {
|
|
||||||
if (row.type === 'vip') {
|
|
||||||
const days = Number(row.days || 0)
|
|
||||||
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
|
|
||||||
}
|
|
||||||
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
|
|
||||||
return row.amount
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'status_label',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
|
|
||||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '使用人',
|
|
||||||
key: 'used_by_user',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.used_by_user?.display_name || '-',
|
|
||||||
},
|
|
||||||
{ title: '创建时间', key: 'created_at', width: 170 },
|
|
||||||
{
|
|
||||||
title: '过期时间',
|
|
||||||
key: 'expires_at',
|
|
||||||
width: 170,
|
|
||||||
render: (row) => row.expires_at || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '备注',
|
|
||||||
key: 'note',
|
|
||||||
minWidth: 160,
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render: (row) => row.note || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 140,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => {
|
|
||||||
const actions = [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => openDrawer(row.id),
|
|
||||||
}, () => '详情'),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (row.status === 'active') {
|
|
||||||
actions.push(h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'warning',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => handleDisable(row),
|
|
||||||
}, () => '禁用'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDateValue(value) {
|
|
||||||
const date = new Date(value)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
const hours = `${date.getHours()}`.padStart(2, '0')
|
|
||||||
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
|
||||||
const seconds = `${date.getSeconds()}`.padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParams(page = pager.page, pageSize = pager.pageSize) {
|
|
||||||
const params = {
|
|
||||||
page,
|
|
||||||
per_page: pageSize,
|
|
||||||
}
|
|
||||||
if (filters.keyword) params.keyword = filters.keyword
|
|
||||||
if (filters.status) params.status = filters.status
|
|
||||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
|
||||||
const [start, end] = filters.dateRange
|
|
||||||
params.date_from = formatDateValue(start).slice(0, 10)
|
|
||||||
params.date_to = formatDateValue(end).slice(0, 10)
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request('/payment/charge-cards', { params: buildParams() })
|
|
||||||
const data = res?.data || {}
|
|
||||||
rows.value = data.items || []
|
|
||||||
pager.itemCount = data.total || 0
|
|
||||||
Object.assign(stats, data.stats || {})
|
|
||||||
optionsState.statuses = data.options?.statuses || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载充值卡台账失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
filters.keyword = ''
|
|
||||||
filters.status = ''
|
|
||||||
filters.dateRange = null
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
submittingCreate.value = true
|
|
||||||
try {
|
|
||||||
await api.request('/payment/charge-cards', {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: createForm.type,
|
|
||||||
password: createForm.password,
|
|
||||||
amount: String(createForm.amount ?? ''),
|
|
||||||
level: createForm.level,
|
|
||||||
days: createForm.days,
|
|
||||||
points: createForm.points,
|
|
||||||
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
|
|
||||||
note: createForm.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('充值卡已创建')
|
|
||||||
createVisible.value = false
|
|
||||||
createForm.password = ''
|
|
||||||
createForm.note = ''
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '创建失败')
|
|
||||||
} finally {
|
|
||||||
submittingCreate.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBatchCreate() {
|
|
||||||
submittingBatch.value = true
|
|
||||||
try {
|
|
||||||
await api.request('/payment/charge-cards/batch', {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: batchForm.type,
|
|
||||||
count: batchForm.count,
|
|
||||||
amount: String(batchForm.amount ?? ''),
|
|
||||||
level: batchForm.level,
|
|
||||||
days: batchForm.days,
|
|
||||||
points: batchForm.points,
|
|
||||||
prefix: batchForm.prefix,
|
|
||||||
length: batchForm.length,
|
|
||||||
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
|
|
||||||
note: batchForm.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('充值卡已批量生成')
|
|
||||||
batchVisible.value = false
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '批量生成失败')
|
|
||||||
} finally {
|
|
||||||
submittingBatch.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDisable(row) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认禁用',
|
|
||||||
content: `确定要禁用充值卡 ${row.password} 吗?`,
|
|
||||||
positiveText: '禁用',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
|
|
||||||
message.success('充值卡已禁用')
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '禁用失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDrawer(id) {
|
|
||||||
currentCardId.value = id
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
try {
|
|
||||||
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
|
|
||||||
const items = res?.data?.items || []
|
|
||||||
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
|
|
||||||
const lines = items.map((row) => [
|
|
||||||
row.id,
|
|
||||||
row.password,
|
|
||||||
row.amount,
|
|
||||||
row.balance,
|
|
||||||
row.status_label,
|
|
||||||
row.used_by_user?.display_name || '',
|
|
||||||
row.created_at || '',
|
|
||||||
row.expires_at || '',
|
|
||||||
row.note || '',
|
|
||||||
])
|
|
||||||
downloadCsv('charge-cards.csv', [header, ...lines])
|
|
||||||
message.success('导出完成')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '导出失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCsv(filename, rows) {
|
|
||||||
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-charge-card-page__stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-charge-card-page__card {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PaymentSectionPage
|
|
||||||
section="gateway"
|
|
||||||
title="收款接口"
|
|
||||||
desc="管理余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换通道。"
|
|
||||||
note="以渠道开关与凭据是否齐全为准;异步回调由各网关验签。卡密为兑换入口,不进入收银台。"
|
|
||||||
:links="[
|
|
||||||
{ label: '余额充值', path: '/payment/recharge' },
|
|
||||||
{ label: '充值卡', path: '/payment/card-pass' },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<template #before-form>
|
|
||||||
<n-spin :show="loading">
|
|
||||||
<n-card class="aksrt-section-card" title="网关状态">
|
|
||||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="gw in gateways" :key="gw.id">
|
|
||||||
<div class="aksrt-gateway__card">
|
|
||||||
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
|
|
||||||
<i :class="iconFor(gw.id)"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{{ gw.name }}</strong>
|
|
||||||
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
|
|
||||||
</div>
|
|
||||||
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
|
|
||||||
{{ gw.configured ? '可用' : '不可用' }}
|
|
||||||
</n-tag>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card v-if="overview.help_text" class="aksrt-section-card" title="运行说明">
|
|
||||||
<p class="aksrt-gateway__help">{{ overview.help_text }}</p>
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</template>
|
|
||||||
</PaymentSectionPage>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const gateways = ref([])
|
|
||||||
const overview = ref({})
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
function iconFor(id) {
|
|
||||||
if (id === 'balance') return 'fa fas fa-credit-card'
|
|
||||||
if (id === 'alipay' || id === 'epay_alipay') return 'fa fas fa-credit-card'
|
|
||||||
if (id === 'wechatpay' || id === 'epay_wxpay') return 'fa fab fa-weixin'
|
|
||||||
if (id === 'paypal') return 'fa fab fa-paypal'
|
|
||||||
if (id === 'stripe') return 'fa fab fa-cc-stripe'
|
|
||||||
return 'fa fas fa-credit-card'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [gwRes, ovRes] = await Promise.all([
|
|
||||||
api.request('/payment/overview').catch(() => null),
|
|
||||||
])
|
|
||||||
if (gwRes?.data?.gateway?.gateways) {
|
|
||||||
const gwList = Object.entries(gwRes.data.gateway.gateways).map(([id, enabled]) => ({ id, name: id, configured: !!enabled }))
|
|
||||||
gateways.value = gwList
|
|
||||||
}
|
|
||||||
if (ovRes?.data?.gateway) {
|
|
||||||
overview.value = ovRes.data.gateway
|
|
||||||
} else if (ovRes?.data) {
|
|
||||||
overview.value = ovRes.data
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.warning('部分支付接口暂不可用')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-gateway__card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface-2, #fafafa);
|
|
||||||
border: 1px solid var(--color-line, #f0f0f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--surface-3, #f0f0f0);
|
|
||||||
color: var(--aksrt-color-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__icon--active {
|
|
||||||
background: var(--color-primary-soft, #f0f5ff);
|
|
||||||
color: var(--aksrt-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__card strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--aksrt-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__card p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--aksrt-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__help {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-ink-3, #5c6370);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PaymentSectionPage
|
|
||||||
section="revenue"
|
|
||||||
title="创作中心"
|
|
||||||
desc="查看现金与积分收益统计及结算运营入口。"
|
|
||||||
:links="[
|
|
||||||
{ label: 'VIP 会员', path: '/payment/vip' },
|
|
||||||
{ label: '推广返佣', path: '/payment/affiliate' },
|
|
||||||
{ label: '创作分成运营', path: '/payment/income' },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<template #before-form>
|
|
||||||
<n-card class="aksrt-section-card" title="收益统计">
|
|
||||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="item in revenueSummary" :key="item.key">
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ item.value }}</strong>
|
|
||||||
<p>{{ item.label }}</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" title="收益趋势">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-space size="small">
|
|
||||||
<n-button :type="granularity === 'day' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('day')">日</n-button>
|
|
||||||
<n-button :type="granularity === 'week' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('week')">周</n-button>
|
|
||||||
<n-button :type="granularity === 'month' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('month')">月</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="hasTimeSeries" class="aksrt-revenue__charts">
|
|
||||||
<div v-for="group in timeSeriesGroups" :key="group.key" class="aksrt-revenue__chart">
|
|
||||||
<h4>{{ group.label }}</h4>
|
|
||||||
<div v-if="group.items.length > 0" class="aksrt-revenue__chart-bars">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in group.items"
|
|
||||||
:key="`${group.key}-${index}`"
|
|
||||||
class="aksrt-revenue__chart-bar-wrap"
|
|
||||||
:title="`${item.date}: ${formatAmount(item.amount, group.key)} (${item.count}笔)`"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="aksrt-revenue__chart-bar"
|
|
||||||
:style="{ height: getBarHeight(item.amount, group.items) + '%' }"
|
|
||||||
/>
|
|
||||||
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-empty v-else size="small" description="暂无数据" />
|
|
||||||
<div class="aksrt-revenue__chart-summary">
|
|
||||||
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
|
|
||||||
<span>共 {{ group.count }} 笔</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-empty v-else description="暂无趋势数据" />
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" title="提现统计">
|
|
||||||
<n-grid :cols="6" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ withdrawalStats.total }}</strong>
|
|
||||||
<p>提现笔数</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ withdrawalStats.pending }}</strong>
|
|
||||||
<p>待处理</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ withdrawalStats.processing }}</strong>
|
|
||||||
<p>处理中</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ withdrawalStats.completed }}</strong>
|
|
||||||
<p>已完成</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ withdrawalStats.rejected }}</strong>
|
|
||||||
<p>已拒绝</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<div class="aksrt-revenue__stat">
|
|
||||||
<strong>{{ formatAmount(withdrawalStats.total_paid, 'cash') }}</strong>
|
|
||||||
<p>已提现 (元)</p>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
</PaymentSectionPage>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const emptyCurrencyStats = (currency) => ({
|
|
||||||
total: currency === 'cash' ? '0.00' : 0,
|
|
||||||
pending: currency === 'cash' ? '0.00' : 0,
|
|
||||||
settled: currency === 'cash' ? '0.00' : 0,
|
|
||||||
count: 0,
|
|
||||||
})
|
|
||||||
const revenueStats = ref({ cash: emptyCurrencyStats('cash'), points: emptyCurrencyStats('points') })
|
|
||||||
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: '0.00' })
|
|
||||||
const timeSeries = ref({ cash: [], points: [] })
|
|
||||||
const granularity = ref('day')
|
|
||||||
|
|
||||||
const revenueSummary = computed(() => [
|
|
||||||
{ key: 'cash-total', value: `${formatAmount(revenueStats.value.cash.total, 'cash')} 元`, label: '现金总收益' },
|
|
||||||
{ key: 'cash-pending', value: `${formatAmount(revenueStats.value.cash.pending, 'cash')} 元`, label: '现金待结算' },
|
|
||||||
{ key: 'cash-settled', value: `${formatAmount(revenueStats.value.cash.settled, 'cash')} 元`, label: '现金已结算' },
|
|
||||||
{ key: 'cash-count', value: revenueStats.value.cash.count, label: '现金收益笔数' },
|
|
||||||
{ key: 'points-total', value: `${formatAmount(revenueStats.value.points.total, 'points')}`, label: '积分总收益' },
|
|
||||||
{ key: 'points-pending', value: `${formatAmount(revenueStats.value.points.pending, 'points')}`, label: '积分待结算' },
|
|
||||||
{ key: 'points-settled', value: `${formatAmount(revenueStats.value.points.settled, 'points')}`, label: '积分已结算' },
|
|
||||||
{ key: 'points-count', value: revenueStats.value.points.count, label: '积分收益笔数' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const timeSeriesGroups = computed(() => [
|
|
||||||
{
|
|
||||||
key: 'cash',
|
|
||||||
label: '现金收益',
|
|
||||||
items: Array.isArray(timeSeries.value.cash) ? timeSeries.value.cash : [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'points',
|
|
||||||
label: '积分收益',
|
|
||||||
items: Array.isArray(timeSeries.value.points) ? timeSeries.value.points : [],
|
|
||||||
},
|
|
||||||
].map((group) => {
|
|
||||||
const totalMinor = group.items.reduce((sum, item) => sum + amountMinor(item.amount, group.key), 0n)
|
|
||||||
return {
|
|
||||||
...group,
|
|
||||||
total: formatMinor(totalMinor, group.key),
|
|
||||||
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const hasTimeSeries = computed(() => timeSeriesGroups.value.some((group) => group.items.length > 0))
|
|
||||||
|
|
||||||
function formatAmount(amount, currency) {
|
|
||||||
return currency === 'points'
|
|
||||||
? `${formatMinor(amountMinor(amount, currency), currency)} 积分`
|
|
||||||
: `¥${formatMinor(amountMinor(amount, currency), currency)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function amountMinor(amount, currency) {
|
|
||||||
const raw = String(amount ?? '').trim()
|
|
||||||
if (currency === 'points') return /^\d+$/.test(raw) ? BigInt(raw) : 0n
|
|
||||||
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw)
|
|
||||||
return match ? (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0') : 0n
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMinor(minor, currency) {
|
|
||||||
if (currency === 'points') return String(minor)
|
|
||||||
return `${minor / 100n}.${String(minor % 100n).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBarHeight(amount, items) {
|
|
||||||
const currency = items === timeSeries.value.points ? 'points' : 'cash'
|
|
||||||
const maxAmount = items.reduce((max, item) => {
|
|
||||||
const current = amountMinor(item.amount, currency)
|
|
||||||
return current > max ? current : max
|
|
||||||
}, 0n)
|
|
||||||
if (maxAmount <= 0n) return 2
|
|
||||||
const scaled = Number((amountMinor(amount, currency) * 10000n) / maxAmount) / 100
|
|
||||||
return Math.max(2, scaled)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLabel(date) {
|
|
||||||
return String(date || '').slice(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGranularity(nextGranularity) {
|
|
||||||
granularity.value = nextGranularity
|
|
||||||
loadRevenue()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRevenue() {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
time_series: '1',
|
|
||||||
granularity: granularity.value,
|
|
||||||
})
|
|
||||||
const res = await api.request(`/payment/revenue?${params}`)
|
|
||||||
if (res?.data) {
|
|
||||||
if (res.data.revenue) {
|
|
||||||
revenueStats.value = {
|
|
||||||
cash: { ...emptyCurrencyStats('cash'), ...(res.data.revenue.cash || {}) },
|
|
||||||
points: { ...emptyCurrencyStats('points'), ...(res.data.revenue.points || {}) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (res.data.withdrawal) {
|
|
||||||
withdrawalStats.value = {
|
|
||||||
total: Number(res.data.withdrawal.total || 0),
|
|
||||||
pending: Number(res.data.withdrawal.pending || 0),
|
|
||||||
processing: Number(res.data.withdrawal.processing || 0),
|
|
||||||
completed: Number(res.data.withdrawal.completed || 0),
|
|
||||||
rejected: Number(res.data.withdrawal.rejected || 0),
|
|
||||||
total_paid: String(res.data.withdrawal.total_paid || '0.00'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timeSeries.value = {
|
|
||||||
cash: res.data.time_series?.cash || [],
|
|
||||||
points: res.data.time_series?.points || [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadRevenue)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-revenue__stat {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface-2, #fafafa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__stat strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--aksrt-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__stat p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--aksrt-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__charts {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart h4 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
color: var(--aksrt-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
height: 180px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-bottom: 1px solid var(--color-line, #e5e7eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-bar-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-bar {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 32px;
|
|
||||||
min-height: 2px;
|
|
||||||
background: var(--color-primary, #88abda);
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
transition: height 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-bar-wrap:hover .aksrt-revenue__chart-bar {
|
|
||||||
background: var(--color-primary-hover, #7099cc);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--aksrt-color-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-summary {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-ink-3, #525866);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-revenue__chart-summary strong {
|
|
||||||
color: var(--aksrt-color-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader :title="title" :desc="desc">
|
|
||||||
<template #actions>
|
|
||||||
<n-space v-if="links.length">
|
|
||||||
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
|
|
||||||
{{ link.label }}
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-card v-if="note" class="aksrt-section-card">
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
{{ note }}
|
|
||||||
</n-alert>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<slot name="before-form" />
|
|
||||||
|
|
||||||
<AksFormRenderer
|
|
||||||
:schema="pageMeta.groups"
|
|
||||||
:form-data="formData"
|
|
||||||
:dirty="dirty"
|
|
||||||
:loading="loading"
|
|
||||||
:saving="saving"
|
|
||||||
:load-error="loadError"
|
|
||||||
:show-search="false"
|
|
||||||
@update:form-data="formData = $event"
|
|
||||||
@save="handleSave"
|
|
||||||
@reload="handleReload"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
|
||||||
import { useSectionForm } from '@/composables/useSectionForm'
|
|
||||||
import { getSchema } from '@/schema'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
section: { type: String, required: true },
|
|
||||||
title: { type: String, required: true },
|
|
||||||
desc: { type: String, default: '' },
|
|
||||||
note: { type: String, default: '' },
|
|
||||||
links: { type: Array, default: () => [] },
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
|
|
||||||
const defaults = computed(() => {
|
|
||||||
const result = {}
|
|
||||||
;(pageMeta.value.groups || []).forEach((group) => {
|
|
||||||
;(group.fields || []).forEach((field) => {
|
|
||||||
if (field.default !== undefined) {
|
|
||||||
result[field.key] = field.default
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
formData,
|
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
loadError,
|
|
||||||
dirty,
|
|
||||||
save: doSave,
|
|
||||||
reload,
|
|
||||||
} = useSectionForm({
|
|
||||||
fetch: () => api.getSchemaPage('payment', props.section),
|
|
||||||
submit: (data) => api.saveSchemaPage('payment', props.section, data),
|
|
||||||
defaults: defaults.value,
|
|
||||||
onSaved: () => {
|
|
||||||
message.success('设置已保存')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
try {
|
|
||||||
await doSave()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReload() {
|
|
||||||
reload()
|
|
||||||
message.info('已放弃更改')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
|
|
||||||
<n-drawer-content title="充值卡详情" closable>
|
|
||||||
<n-spin :show="loading">
|
|
||||||
<template v-if="detail.card">
|
|
||||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
|
||||||
<n-descriptions-item label="卡密">{{ detail.card.password || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="使用者">
|
|
||||||
{{ detail.card.used_by_user?.display_name || '-' }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
|
|
||||||
<div class="aksrt-charge-drawer__section">
|
|
||||||
<div class="aksrt-charge-drawer__title">备注</div>
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
{{ detail.card.note || '暂无备注' }}
|
|
||||||
</n-alert>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-empty v-else description="暂无充值卡详情" />
|
|
||||||
</n-spin>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: { type: Boolean, default: false },
|
|
||||||
cardId: { type: [Number, String], default: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:show'])
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(false)
|
|
||||||
const detail = ref({ card: null })
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.show, props.cardId],
|
|
||||||
async ([visible, id]) => {
|
|
||||||
if (visible && id) {
|
|
||||||
await loadDetail()
|
|
||||||
}
|
|
||||||
if (!visible) {
|
|
||||||
detail.value = { card: null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function loadDetail() {
|
|
||||||
if (!props.cardId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
|
|
||||||
detail.value = res?.data || { card: null }
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载充值卡详情失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-charge-drawer__section {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-charge-drawer__title {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="附件治理"
|
|
||||||
desc="管理各类上传文件的大小、安全限制和审核策略;准入权限请在“基本权限”中配置。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-space>
|
|
||||||
<n-button quaternary @click="goModeration">审核策略</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
|
||||||
<!-- Summary -->
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>上传策略总览</h3>
|
|
||||||
</div>
|
|
||||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="item in summaryItems" :key="item.key">
|
|
||||||
<div class="aksrt-attach__summary">
|
|
||||||
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : 'var(--aksrt-color-muted)'" />
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.label }}</strong>
|
|
||||||
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Avatar & Cover -->
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>用户资料卡背景图</h3>
|
|
||||||
</div>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
|
||||||
<n-form-item label="头像/用户资料卡背景图大小上限 (MB)">
|
|
||||||
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Content uploads -->
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>内容上传</h3>
|
|
||||||
</div>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
|
||||||
<n-form-item label="投稿图片大小上限 (MB)">
|
|
||||||
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="评论图片大小上限 (MB)">
|
|
||||||
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Resource attachments -->
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<h3>资源附件</h3>
|
|
||||||
</div>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
|
||||||
<n-form-item label="资源附件大小上限 (MB)">
|
|
||||||
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
|
||||||
</div>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, reactive, ref, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
avatar_max_size: 2,
|
|
||||||
submit_image_max_size: 5,
|
|
||||||
comment_image_max_size: 2,
|
|
||||||
resource_max_size: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
const summaryItems = computed(() => [
|
|
||||||
{ key: 'profile', icon: 'fa fas fa-id-card', label: '资料卡图片', enabled: true },
|
|
||||||
{ key: 'submit', icon: 'fa fas fa-edit', label: '投稿图片', enabled: true },
|
|
||||||
{ key: 'comment', icon: 'fa fas fa-comment-dots', label: '评论图片', enabled: true },
|
|
||||||
{ key: 'resource', icon: 'fa fas fa-file-archive', label: '资源附件', enabled: true },
|
|
||||||
])
|
|
||||||
|
|
||||||
function go(path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goModeration() { router.push('/permissions/moderation') }
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const permissionRes = await api.getPermissionsAttachmentSettings()
|
|
||||||
|
|
||||||
const permission = permissionRes?.data ?? permissionRes ?? {}
|
|
||||||
|
|
||||||
// Map permission fields
|
|
||||||
form.avatar_max_size = Number(permission.avatar_max_size || 2)
|
|
||||||
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
|
|
||||||
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
|
|
||||||
form.resource_max_size = Number(permission.resource_max_size || 50)
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载附件权限失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await api.savePermissionsAttachmentSettings({
|
|
||||||
avatar_max_size: form.avatar_max_size,
|
|
||||||
submit_image_max_size: form.submit_image_max_size,
|
|
||||||
comment_image_max_size: form.comment_image_max_size,
|
|
||||||
resource_max_size: form.resource_max_size,
|
|
||||||
})
|
|
||||||
message.success('附件治理配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-attach__summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-attach__summary strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--aksrt-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-attach__summary p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--aksrt-color-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="禁封管理"
|
|
||||||
desc="查看和管理用户封禁、限制状态,支持按用户 ID 查询和解除限制。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-grid cols="1 s:2 m:3" :x-gap="16" :y-gap="16" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="总限制记录" :value="stats.total" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="当前生效" :value="stats.active">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.active > 0" type="error" size="small">{{ stats.active }}</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="封禁账号" :value="stats.banned">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.banned > 0" type="error" size="small">严重</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<!-- Search by user ID -->
|
|
||||||
<n-card size="small" title="查询用户限制" style="margin-top:16px;">
|
|
||||||
<n-space>
|
|
||||||
<n-input-number v-model:value="searchUserId" placeholder="用户 ID" :min="1" style="width:160px;" />
|
|
||||||
<n-button type="primary" @click="searchRestrictions" :loading="searching">查询</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- User restrictions list -->
|
|
||||||
<n-card v-if="userRestrictions.length" size="small" :title="`用户 #${searchUserId} 的限制记录`" style="margin-top:16px;">
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="userRestrictions"
|
|
||||||
:loading="searching"
|
|
||||||
:bordered="false"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Add restriction form -->
|
|
||||||
<n-card size="small" title="添加限制" style="margin-top:16px;">
|
|
||||||
<n-form :model="addForm" label-placement="left" label-width="100" class="aksrt-form">
|
|
||||||
<n-form-item label="用户 ID">
|
|
||||||
<n-input-number v-model:value="addForm.user_id" :min="1" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="限制类型">
|
|
||||||
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="原因">
|
|
||||||
<n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="持续时间">
|
|
||||||
<n-select v-model:value="addForm.duration" :options="durationOptions" style="width:200px;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-button type="error" @click="addRestriction" :loading="adding" :disabled="!addForm.user_id || !addForm.reason">
|
|
||||||
添加限制
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { h, reactive, ref, onMounted } from 'vue'
|
|
||||||
import { NButton, NTag, NSpace, useMessage } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const searching = ref(false)
|
|
||||||
const adding = ref(false)
|
|
||||||
const searchUserId = ref(null)
|
|
||||||
const userRestrictions = ref([])
|
|
||||||
const stats = reactive({ total: 0, active: 0, banned: 0 })
|
|
||||||
|
|
||||||
const addForm = reactive({
|
|
||||||
user_id: null,
|
|
||||||
type: 'muted',
|
|
||||||
reason: '',
|
|
||||||
duration: '7d',
|
|
||||||
})
|
|
||||||
|
|
||||||
const restrictionTypes = [
|
|
||||||
{ label: '禁言', value: 'muted' },
|
|
||||||
{ label: '限制', value: 'restricted' },
|
|
||||||
{ label: '封禁', value: 'banned' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const durationOptions = [
|
|
||||||
{ label: '1 天', value: '1d' },
|
|
||||||
{ label: '3 天', value: '3d' },
|
|
||||||
{ label: '7 天', value: '7d' },
|
|
||||||
{ label: '30 天', value: '30d' },
|
|
||||||
{ label: '永久', value: 'permanent' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 60 },
|
|
||||||
{
|
|
||||||
title: '类型', key: 'type', width: 80,
|
|
||||||
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
|
|
||||||
},
|
|
||||||
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '生效时间', key: 'created_at', width: 160 },
|
|
||||||
{ title: '到期时间', key: 'expires_at', width: 160 },
|
|
||||||
{
|
|
||||||
title: '状态', key: 'status', width: 80,
|
|
||||||
render: (row) => {
|
|
||||||
const active = !row.expires_at || new Date(row.expires_at) > new Date()
|
|
||||||
return h(NTag, { type: active ? 'error' : 'default', size: 'small' }, () => active ? '生效中' : '已过期')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作', key: 'actions', width: 100,
|
|
||||||
render: (row) => h(NButton, { size: 'tiny', type: 'error', onClick: () => removeRestriction(row) }, () => '解除'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTypeTag(type) {
|
|
||||||
const map = { muted: 'warning', restricted: 'error', banned: 'error' }
|
|
||||||
return map[type] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeLabel(type) {
|
|
||||||
const map = { muted: '禁言', restricted: '限制', banned: '封禁' }
|
|
||||||
return map[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const res = await api.request('/users/restrictions/stats')
|
|
||||||
const data = res.data || res
|
|
||||||
stats.total = data.total || 0
|
|
||||||
stats.active = data.active || 0
|
|
||||||
stats.banned = data.banned || 0
|
|
||||||
} catch (e) {
|
|
||||||
// Stats may not be available yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchRestrictions() {
|
|
||||||
if (!searchUserId.value) {
|
|
||||||
message.warning('请输入用户 ID')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searching.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request(`/users/${searchUserId.value}/restrictions`)
|
|
||||||
const data = res.data || res
|
|
||||||
userRestrictions.value = data.restrictions || []
|
|
||||||
if (userRestrictions.value.length === 0) {
|
|
||||||
message.info('该用户无限制记录')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error('查询失败: ' + (e.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
searching.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRestriction() {
|
|
||||||
if (!addForm.user_id || !addForm.reason) {
|
|
||||||
message.warning('请填写用户 ID 和原因')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
adding.value = true
|
|
||||||
try {
|
|
||||||
await api.request(`/users/${addForm.user_id}/restrictions`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: addForm.type,
|
|
||||||
reason: addForm.reason,
|
|
||||||
duration: addForm.duration,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('限制已添加')
|
|
||||||
addForm.reason = ''
|
|
||||||
if (searchUserId.value === addForm.user_id) {
|
|
||||||
await searchRestrictions()
|
|
||||||
}
|
|
||||||
await loadStats()
|
|
||||||
} catch (e) {
|
|
||||||
message.error('添加失败: ' + (e.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
adding.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRestriction(row) {
|
|
||||||
try {
|
|
||||||
await api.request(`/users/restrictions/${row.id}`, { method: 'DELETE' })
|
|
||||||
message.success('限制已解除')
|
|
||||||
userRestrictions.value = userRestrictions.value.filter((r) => r.id !== row.id)
|
|
||||||
await loadStats()
|
|
||||||
} catch (e) {
|
|
||||||
message.error('解除失败: ' + (e.message || '未知错误'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadStats)
|
|
||||||
</script>
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="消息中心"
|
|
||||||
desc="管理站内通知、系统消息和用户消息数据。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="全部消息" :value="stats.total" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="未读消息" :value="stats.unread">
|
|
||||||
<template #suffix>
|
|
||||||
<n-tag v-if="stats.unread > 0" type="warning" size="small">{{ stats.unread }}</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-statistic>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="今日新增" :value="stats.today" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-statistic label="系统通知" :value="stats.system" />
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-card size="small" title="消息列表" style="margin-top:16px;">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-space>
|
|
||||||
<n-select
|
|
||||||
v-model:value="filters.type"
|
|
||||||
placeholder="消息类型"
|
|
||||||
:options="typeOptions"
|
|
||||||
clearable
|
|
||||||
style="width:160px;"
|
|
||||||
@update:value="handleFilterChange"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="filters.read"
|
|
||||||
placeholder="已读状态"
|
|
||||||
:options="readOptions"
|
|
||||||
clearable
|
|
||||||
style="width:120px;"
|
|
||||||
@update:value="handleFilterChange"
|
|
||||||
/>
|
|
||||||
<n-button @click="loadMessages" :loading="loading">刷新</n-button>
|
|
||||||
<n-button type="primary" @click="markAllRead" :loading="markingAll">全部已读</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="messages"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-class-name="rowClassName"
|
|
||||||
remote
|
|
||||||
@update:page="handlePageChange"
|
|
||||||
@update:page-size="handlePageSizeChange"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-drawer v-model:show="showDetail" :width="480">
|
|
||||||
<n-drawer-content :title="detailMessage?.title || '消息详情'">
|
|
||||||
<template v-if="detailMessage">
|
|
||||||
<n-descriptions bordered :column="1" label-placement="left">
|
|
||||||
<n-descriptions-item label="类型">
|
|
||||||
<n-tag :type="getTypeTag(detailMessage.type)" size="small">{{ getTypeLabel(detailMessage.type) }}</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="状态">
|
|
||||||
<n-tag :type="detailMessage.is_read ? 'success' : 'warning'" size="small">
|
|
||||||
{{ detailMessage.is_read ? '已读' : '未读' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="用户">{{ detailMessage.user_name || '-' }}</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="时间">{{ detailMessage.created_at || '-' }}</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
|
|
||||||
<div class="aksrt-message-detail">
|
|
||||||
<h4>{{ detailMessage.title }}</h4>
|
|
||||||
<p>{{ detailMessage.description || detailMessage.content || '暂无详细内容' }}</p>
|
|
||||||
<a
|
|
||||||
v-if="detailMessage.target_url"
|
|
||||||
:href="detailMessage.target_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
查看目标
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<n-space>
|
|
||||||
<n-button @click="showDetail = false">关闭</n-button>
|
|
||||||
<n-button v-if="detailMessage && !detailMessage.is_read" type="primary" @click="markCurrentRead">
|
|
||||||
标记已读
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const markingAll = ref(false)
|
|
||||||
const showDetail = ref(false)
|
|
||||||
const detailMessage = ref(null)
|
|
||||||
const messages = ref([])
|
|
||||||
const typeStats = ref([])
|
|
||||||
const stats = reactive({ total: 0, unread: 0, today: 0, system: 0 })
|
|
||||||
const filters = reactive({ type: null, read: null })
|
|
||||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, pageCount: 1, showSizePicker: true, pageSizes: [20, 50, 100] })
|
|
||||||
|
|
||||||
const readOptions = [
|
|
||||||
{ label: '未读', value: '0' },
|
|
||||||
{ label: '已读', value: '1' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const typeOptions = computed(() => typeStats.value.map((item) => ({
|
|
||||||
label: getTypeLabel(item.type),
|
|
||||||
value: item.type,
|
|
||||||
})))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
|
|
||||||
},
|
|
||||||
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '用户', key: 'user_name', width: 140, ellipsis: { tooltip: true } },
|
|
||||||
{ title: '时间', key: 'created_at', width: 180 },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'is_read',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => h(NTag, { type: row.is_read ? 'success' : 'warning', size: 'small' }, () => (row.is_read ? '已读' : '未读')),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 140,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => openDetail(row) }, () => '查看'),
|
|
||||||
!row.is_read ? h(NButton, { size: 'tiny', type: 'primary', onClick: () => markOneRead(row) }, () => '已读') : null,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTypeTag(type) {
|
|
||||||
const map = {
|
|
||||||
notice: 'info',
|
|
||||||
system: 'info',
|
|
||||||
comment_reply: 'success',
|
|
||||||
comment_approved: 'success',
|
|
||||||
download_ready: 'primary',
|
|
||||||
verify_email: 'info',
|
|
||||||
pending_comments: 'warning',
|
|
||||||
}
|
|
||||||
return map[type] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeLabel(type) {
|
|
||||||
const map = {
|
|
||||||
notice: '系统通知',
|
|
||||||
system: '系统通知',
|
|
||||||
comment_reply: '评论回复',
|
|
||||||
comment_approved: '评论审核',
|
|
||||||
download_ready: '下载就绪',
|
|
||||||
verify_email: '邮箱验证',
|
|
||||||
pending_comments: '待审评论',
|
|
||||||
}
|
|
||||||
return map[type] || type || '通知'
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowClassName(row) {
|
|
||||||
return row.is_read ? '' : 'aksrt-row--unread'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMessages() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await api.getNotificationOverview({
|
|
||||||
page: pagination.page,
|
|
||||||
per_page: pagination.pageSize,
|
|
||||||
...(filters.type ? { type: filters.type } : {}),
|
|
||||||
...(filters.read !== null ? { is_read: filters.read } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const payload = response.data || {}
|
|
||||||
const summary = payload.summary || {}
|
|
||||||
const pager = payload.pagination || {}
|
|
||||||
|
|
||||||
messages.value = Array.isArray(payload.recent) ? payload.recent : []
|
|
||||||
typeStats.value = Array.isArray(payload.types) ? payload.types : []
|
|
||||||
|
|
||||||
stats.total = Number(summary.total || payload.total || 0)
|
|
||||||
stats.unread = Number(summary.unread || payload.unread || 0)
|
|
||||||
stats.today = Number(summary.today || payload.today || 0)
|
|
||||||
stats.system = Number(summary.system || payload.system || 0)
|
|
||||||
|
|
||||||
pagination.itemCount = Number(pager.total_items || messages.value.length)
|
|
||||||
pagination.pageCount = Number(pager.total_pages || 1)
|
|
||||||
pagination.page = Number(pager.page || pagination.page)
|
|
||||||
pagination.pageSize = Number(pager.per_page || pagination.pageSize)
|
|
||||||
} catch (error) {
|
|
||||||
message.error('加载消息失败: ' + (error.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(row) {
|
|
||||||
detailMessage.value = row
|
|
||||||
showDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markOneRead(row) {
|
|
||||||
try {
|
|
||||||
await api.markNotificationRead(row.id)
|
|
||||||
row.is_read = true
|
|
||||||
stats.unread = Math.max(0, stats.unread - 1)
|
|
||||||
if (detailMessage.value && detailMessage.value.id === row.id) {
|
|
||||||
detailMessage.value.is_read = true
|
|
||||||
}
|
|
||||||
message.success('已标记为已读')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markCurrentRead() {
|
|
||||||
if (detailMessage.value) {
|
|
||||||
await markOneRead(detailMessage.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAllRead() {
|
|
||||||
markingAll.value = true
|
|
||||||
try {
|
|
||||||
await api.markAllNotificationsRead()
|
|
||||||
messages.value = messages.value.map((item) => ({ ...item, is_read: true }))
|
|
||||||
if (detailMessage.value) {
|
|
||||||
detailMessage.value = { ...detailMessage.value, is_read: true }
|
|
||||||
}
|
|
||||||
stats.unread = 0
|
|
||||||
message.success('全部已读')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '操作失败')
|
|
||||||
} finally {
|
|
||||||
markingAll.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFilterChange() {
|
|
||||||
pagination.page = 1
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageChange(page) {
|
|
||||||
pagination.page = page
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageSizeChange(pageSize) {
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
pagination.page = 1
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadMessages)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-row--unread {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail h4 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-message-detail p {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.7;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader
|
|
||||||
title="举报治理"
|
|
||||||
desc="统一维护公开举报入口与后台工单台,支持证据查看、状态流转和处罚语义联动。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small" title="治理开关">
|
|
||||||
<n-space wrap>
|
|
||||||
<n-tag round :type="form.user_report_enabled ? 'success' : 'default'">
|
|
||||||
举报入口:{{ form.user_report_enabled ? '已启用' : '已关闭' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="form.user_report_public_entry ? 'info' : 'default'">
|
|
||||||
公开入口:{{ form.user_report_public_entry ? '显示' : '隐藏' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round :type="moderationSummary.enabled ? 'success' : 'default'">
|
|
||||||
审核系统:{{ moderationSummary.enabled ? '已启用' : '未启用' }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="warning">
|
|
||||||
待处理:{{ counts.pending }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag round type="success">
|
|
||||||
已解决:{{ counts.resolved }}
|
|
||||||
</n-tag>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small" title="前台说明">
|
|
||||||
<n-form :model="form" label-placement="top">
|
|
||||||
<n-form-item label="限制提示文案">
|
|
||||||
<n-input v-model:value="form.user_report_ban_notice_text" maxlength="80" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="举报分类字典">
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.user_report_categories"
|
|
||||||
type="textarea"
|
|
||||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
|
||||||
placeholder="spam|垃圾信息 abuse|人身攻击"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-card size="small" title="入口配置" class="aksrt-report-page__card">
|
|
||||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
|
||||||
<n-form-item label="启用举报/禁封结构">
|
|
||||||
<n-switch v-model:value="form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="公开主页展示举报入口">
|
|
||||||
<n-switch v-model:value="form.user_report_public_entry" :disabled="!form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="公开显示限制提示">
|
|
||||||
<n-switch v-model:value="form.user_report_public_notice" :disabled="!form.user_report_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card size="small" title="举报工单台" class="aksrt-report-page__card">
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索举报人、目标、原因" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.target_type" :options="targetTypeOptions" placeholder="目标类型" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-button type="primary" block @click="handleSearch">筛选工单</n-button>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="reports"
|
|
||||||
:loading="reportLoading"
|
|
||||||
:bordered="false"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<ReportDetailDrawer
|
|
||||||
v-model:show="drawerVisible"
|
|
||||||
:report-id="currentReportId"
|
|
||||||
@updated="loadReports"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import ReportDetailDrawer from './components/ReportDetailDrawer.vue'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const reportLoading = ref(false)
|
|
||||||
const reports = ref([])
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const currentReportId = ref(null)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
user_report_enabled: false,
|
|
||||||
user_report_public_entry: true,
|
|
||||||
user_report_public_notice: true,
|
|
||||||
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。',
|
|
||||||
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉',
|
|
||||||
})
|
|
||||||
|
|
||||||
const moderationSummary = reactive({
|
|
||||||
enabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
keyword: '',
|
|
||||||
status: '',
|
|
||||||
target_type: '',
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const counts = reactive({
|
|
||||||
pending: 0,
|
|
||||||
reviewing: 0,
|
|
||||||
resolved: 0,
|
|
||||||
dismissed: 0,
|
|
||||||
total: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const pager = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
itemCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optionsState = reactive({
|
|
||||||
statuses: [],
|
|
||||||
targets: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusOptions = computed(() => optionsState.statuses.length
|
|
||||||
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
|
|
||||||
: [
|
|
||||||
{ label: '全部状态', value: '' },
|
|
||||||
{ label: '待处理', value: 'pending' },
|
|
||||||
{ label: '处理中', value: 'reviewing' },
|
|
||||||
{ label: '已解决', value: 'resolved' },
|
|
||||||
{ label: '已驳回', value: 'dismissed' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeOptions = computed(() => optionsState.targets.length
|
|
||||||
? [{ label: '全部类型', value: '' }, ...optionsState.targets]
|
|
||||||
: [
|
|
||||||
{ label: '全部类型', value: '' },
|
|
||||||
{ label: '文章', value: 'post' },
|
|
||||||
{ label: '评论', value: 'comment' },
|
|
||||||
{ label: '用户', value: 'user' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
|
||||||
page: pager.page,
|
|
||||||
pageSize: pager.pageSize,
|
|
||||||
itemCount: pager.itemCount,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 20, 50],
|
|
||||||
onUpdatePage: (page) => {
|
|
||||||
pager.page = page
|
|
||||||
loadReports()
|
|
||||||
},
|
|
||||||
onUpdatePageSize: (pageSize) => {
|
|
||||||
pager.pageSize = pageSize
|
|
||||||
pager.page = 1
|
|
||||||
loadReports()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
|
||||||
{
|
|
||||||
title: '举报人',
|
|
||||||
key: 'reporter',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.reporter?.display_name || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '目标',
|
|
||||||
key: 'target',
|
|
||||||
minWidth: 220,
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render: (row) => row.target?.title || row.target?.display_name || row.target?.excerpt || `目标 #${row.target_id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap = { pending: 'warning', reviewing: 'info', resolved: 'success', dismissed: 'default' }
|
|
||||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
key: 'category',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => row.category || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '处理记录',
|
|
||||||
key: 'note_count',
|
|
||||||
width: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '提交时间',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 170,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => openDrawer(row.id),
|
|
||||||
}, () => '查看'),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await load()
|
|
||||||
})
|
|
||||||
|
|
||||||
function extractData(response) {
|
|
||||||
return response?.data ?? response ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateValue(value) {
|
|
||||||
const date = new Date(value)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReportParams() {
|
|
||||||
const params = {
|
|
||||||
page: pager.page,
|
|
||||||
per_page: pager.pageSize,
|
|
||||||
}
|
|
||||||
if (filters.keyword) params.keyword = filters.keyword
|
|
||||||
if (filters.status) params.status = filters.status
|
|
||||||
if (filters.target_type) params.target_type = filters.target_type
|
|
||||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
|
||||||
params.date_from = formatDateValue(filters.dateRange[0])
|
|
||||||
params.date_to = formatDateValue(filters.dateRange[1])
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [schemaRes, moderationRes] = await Promise.all([
|
|
||||||
api.getSchemaPage('users', 'report'),
|
|
||||||
api.getPermissionsModerationSettings(),
|
|
||||||
])
|
|
||||||
const schemaData = extractData(schemaRes)
|
|
||||||
const moderationData = extractData(moderationRes)
|
|
||||||
|
|
||||||
form.user_report_enabled = !!schemaData.user_report_enabled
|
|
||||||
form.user_report_public_entry = schemaData.user_report_public_entry !== false
|
|
||||||
form.user_report_public_notice = schemaData.user_report_public_notice !== false
|
|
||||||
form.user_report_ban_notice_text = schemaData.user_report_ban_notice_text || '当前账号处于限制状态,部分互动能力已关闭。'
|
|
||||||
form.user_report_categories = schemaData.user_report_categories || 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉'
|
|
||||||
moderationSummary.enabled = !!moderationData.enabled
|
|
||||||
|
|
||||||
await loadReports()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报治理页失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadReports() {
|
|
||||||
reportLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request('/users/reports', { params: buildReportParams() })
|
|
||||||
const data = res?.data || {}
|
|
||||||
reports.value = data.items || []
|
|
||||||
pager.itemCount = data.total || 0
|
|
||||||
Object.assign(counts, data.counts || {})
|
|
||||||
optionsState.statuses = data.options?.statuses || []
|
|
||||||
optionsState.targets = data.options?.targets || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报工单失败')
|
|
||||||
} finally {
|
|
||||||
reportLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await api.saveSchemaPage('users', 'report', {
|
|
||||||
user_report_enabled: form.user_report_enabled,
|
|
||||||
user_report_public_entry: form.user_report_public_entry,
|
|
||||||
user_report_public_notice: form.user_report_public_notice,
|
|
||||||
user_report_ban_notice_text: form.user_report_ban_notice_text,
|
|
||||||
user_report_categories: form.user_report_categories,
|
|
||||||
})
|
|
||||||
message.success('举报治理配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pager.page = 1
|
|
||||||
loadReports()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDrawer(reportId) {
|
|
||||||
currentReportId.value = reportId
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-report-page__card {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,220 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container">
|
<div class="aksrt-page-container">
|
||||||
<PageHeader
|
<PageHeader title="身份认证" desc="配置通用认证申请规则;申请审核和用户认证资料管理位于 WordPress 用户菜单。" />
|
||||||
title="身份认证"
|
|
||||||
desc="统一管理认证申请规则与云服务商凭证;审核、驳回和取消认证仍在 WordPress 用户菜单处理。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width: 100%; min-height: 240px">
|
<n-spin :show="loading" style="width: 100%; min-height: 240px">
|
||||||
<n-grid :cols="3" :x-gap="12" :y-gap="12" responsive="screen" item-responsive>
|
<n-card class="aksrt-section-card" title="认证申请设置">
|
||||||
<n-grid-item span="3 m:1">
|
<n-form :model="form" label-placement="left" label-width="190">
|
||||||
<n-card class="aksrt-status-card" size="small">
|
<n-form-item label="启用身份认证"><n-switch v-model:value="form.user_auth_s" /></n-form-item>
|
||||||
<n-statistic label="身份认证功能">
|
<n-form-item label="允许用户前台申请"><n-switch v-model:value="form.user_auth_apply_s" :disabled="!form.user_auth_s" /></n-form-item>
|
||||||
<template #default>{{ verifyForm.user_verify_enabled ? '已启用' : '已停用' }}</template>
|
<n-form-item v-if="!form.user_auth_apply_s" label="停用申请说明">
|
||||||
</n-statistic>
|
<n-input v-model:value="form.user_auth_apply_disable_desc" type="textarea" :rows="2" />
|
||||||
<n-tag :type="verifyForm.user_verify_enabled ? 'success' : 'default'" round size="small">
|
|
||||||
{{ verifyForm.user_verify_enabled ? '运行中' : '未运行' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item span="3 m:1" v-for="key in providerKeys" :key="key">
|
|
||||||
<n-card class="aksrt-status-card" size="small">
|
|
||||||
<n-statistic :label="providerLabel(key) + '凭证'">
|
|
||||||
<template #default>{{ providerState(key).configured ? '已配置' : '未配置' }}</template>
|
|
||||||
</n-statistic>
|
|
||||||
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" round size="small">
|
|
||||||
{{ identityForm.provider === key ? '当前服务商' : '备用服务商' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" title="通用认证设置">
|
|
||||||
<n-form :model="verifyForm" label-placement="left" label-width="190">
|
|
||||||
<n-form-item label="启用身份认证"><n-switch v-model:value="verifyForm.user_verify_enabled" /></n-form-item>
|
|
||||||
<n-form-item label="公开页显示认证徽标"><n-switch v-model:value="verifyForm.user_verify_public_display" /></n-form-item>
|
|
||||||
<n-form-item label="允许用户前台申请"><n-switch v-model:value="verifyForm.user_verify_allow_apply" /></n-form-item>
|
|
||||||
<n-form-item label="云失败转人工"><n-switch v-model:value="verifyForm.user_verify_allow_manual_fallback" /></n-form-item>
|
|
||||||
<n-form-item label="认证会话有效期(分钟)"><n-input-number v-model:value="verifyForm.user_verify_session_ttl_minutes" :min="5" :max="1440" /></n-form-item>
|
|
||||||
<n-form-item label="结果轮询间隔(秒)"><n-input-number v-model:value="verifyForm.user_verify_poll_interval_seconds" :min="3" :max="60" /></n-form-item>
|
|
||||||
<n-form-item label="个人认证费用"><n-input v-model:value="verifyForm.user_verify_personal_fee" placeholder="0.00" /></n-form-item>
|
|
||||||
<n-form-item label="企业认证费用"><n-input v-model:value="verifyForm.user_verify_enterprise_fee" placeholder="0.00" /></n-form-item>
|
|
||||||
<n-form-item label="个人认证徽标文案">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_personal_badge_text" placeholder="个人认证" />
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="企业认证徽标文案">
|
<n-form-item v-if="form.user_auth_apply_s" label="申请最低用户等级">
|
||||||
<n-input v-model:value="verifyForm.user_verify_enterprise_badge_text" placeholder="企业认证" />
|
<n-input-number v-model:value="form.user_auth_apply_limit_level" :min="0" :max="30" />
|
||||||
|
<template #feedback>设置为 0 时不限制用户等级。</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="停用申请说明">
|
<n-form-item v-if="form.user_auth_apply_s" label="申请介绍">
|
||||||
<n-input v-model:value="verifyForm.user_verify_disable_desc" type="textarea" :rows="2" />
|
<n-input v-model:value="form.user_auth_apply_desc" type="textarea" :rows="3" />
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="申请介绍">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_desc" type="textarea" :rows="3" />
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" title="云服务商">
|
|
||||||
<n-form :model="identityForm" label-placement="left" label-width="190">
|
|
||||||
<n-form-item label="当前服务商">
|
|
||||||
<n-radio-group v-model:value="identityForm.provider">
|
|
||||||
<n-space>
|
|
||||||
<n-radio-button value="aliyun">阿里云</n-radio-button>
|
|
||||||
<n-radio-button value="tencent">腾讯云</n-radio-button>
|
|
||||||
</n-space>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="回调地址">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input :value="callbackUrlForCurrentProvider" readonly />
|
|
||||||
<n-button @click="copyCallback">复制</n-button>
|
|
||||||
</n-input-group>
|
|
||||||
<template #feedback>将此地址填入服务商控制台需要的回调地址配置。</template>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-tabs v-model:value="activeTab" type="segment" animated class="aksrt-provider-tabs">
|
|
||||||
<n-tab-pane v-for="key in providerKeys" :key="key" :name="key" :tab="providerLabel(key)">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<n-space align="center">
|
|
||||||
<span>{{ providerLabel(key) }}凭证</span>
|
|
||||||
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" size="small" round>
|
|
||||||
{{ providerState(key).configured ? '已配置' : '未配置' }}
|
|
||||||
</n-tag>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
<n-alert type="info" :show-icon="false" style="margin-bottom: 18px">
|
|
||||||
已保存凭证不会回显。留空表示保留原值;输入新值并保存会替换对应凭证。
|
|
||||||
</n-alert>
|
|
||||||
<n-form label-placement="left" label-width="190">
|
|
||||||
<n-form-item v-if="key === 'aliyun'" label="Region">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_aliyun_region" placeholder="cn-hangzhou" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'aliyun'" label="个人认证 SceneId">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_aliyun_personal_flow" placeholder="从阿里云实人认证控制台获取 SceneId" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'aliyun'" label="个人认证 ProductCode">
|
|
||||||
<n-select v-model:value="verifyForm.user_verify_aliyun_product_code" :options="aliyunProductOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'aliyun'" label="企业认证业务标识">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_aliyun_enterprise_flow" placeholder="可留空,默认复用个人认证 SceneId" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'tencent'" label="Region">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_tencent_region" placeholder="ap-guangzhou" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'tencent'" label="个人认证 RuleId">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_tencent_personal_flow" placeholder="从腾讯云实名核身控制台获取 RuleId" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="key === 'tencent'" label="企业认证流程标识">
|
|
||||||
<n-input v-model:value="verifyForm.user_verify_tencent_enterprise_flow" placeholder="可留空,企业四要素后复用个人认证 RuleId" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-for="field in credentialFields[key]" :key="field.key" :label="field.label">
|
|
||||||
<n-input
|
|
||||||
v-model:value="credentials[key][field.key]"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:placeholder="credentialPlaceholder(key, field.key)"
|
|
||||||
autocomplete="new-password"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="显式清除凭证">
|
|
||||||
<n-checkbox v-model:checked="clearCredentials[key]">
|
|
||||||
保存时删除 {{ providerLabel(key) }}全部凭证
|
|
||||||
</n-checkbox>
|
|
||||||
<template #feedback>清除后该服务商立即变为未配置;此操作不会因输入框留空而触发。</template>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="配置测试">
|
|
||||||
<n-button :loading="testing === key" :disabled="saving" @click="testProvider(key)">
|
|
||||||
测试 {{ providerLabel(key) }}配置
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<div class="aksrt-form-actions">
|
<div class="aksrt-form-actions">
|
||||||
<n-button :disabled="!dirty" @click="load">放弃更改</n-button>
|
<n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
|
||||||
<n-button type="primary" :loading="saving" @click="save">保存全部配置</n-button>
|
<n-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存设置</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useDialog, useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
user_auth_s: true,
|
||||||
|
user_auth_apply_s: true,
|
||||||
|
user_auth_apply_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
||||||
|
user_auth_apply_limit_level: 0,
|
||||||
|
user_auth_apply_desc: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
|
||||||
|
}
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const form = reactive({ ...defaults })
|
||||||
const providerKeys = ['aliyun', 'tencent']
|
const initial = ref('')
|
||||||
const aliyunProductOptions = [
|
|
||||||
{ label: '实人认证(ID_PRO)', value: 'ID_PRO' },
|
|
||||||
{ label: '活体人脸验证(PV_FV)', value: 'PV_FV' },
|
|
||||||
{ label: '活体检测(LR_FR)', value: 'LR_FR' },
|
|
||||||
]
|
|
||||||
const credentialFields = {
|
|
||||||
aliyun: [
|
|
||||||
{ key: 'access_key_id', label: 'AccessKey ID' },
|
|
||||||
{ key: 'access_key_secret', label: 'AccessKey Secret' },
|
|
||||||
],
|
|
||||||
tencent: [
|
|
||||||
{ key: 'secret_id', label: 'SecretId' },
|
|
||||||
{ key: 'secret_key', label: 'SecretKey' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const verifyDefaults = {
|
|
||||||
user_verify_enabled: true,
|
|
||||||
user_verify_public_display: true,
|
|
||||||
user_verify_allow_apply: true,
|
|
||||||
user_verify_allow_manual_fallback: true,
|
|
||||||
user_verify_session_ttl_minutes: 30,
|
|
||||||
user_verify_poll_interval_seconds: 5,
|
|
||||||
user_verify_personal_fee: '0.00',
|
|
||||||
user_verify_enterprise_fee: '0.00',
|
|
||||||
user_verify_personal_badge_text: '个人认证',
|
|
||||||
user_verify_enterprise_badge_text: '企业认证',
|
|
||||||
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
|
||||||
user_verify_desc: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
|
|
||||||
user_verify_aliyun_region: 'cn-hangzhou',
|
|
||||||
user_verify_aliyun_personal_flow: '',
|
|
||||||
user_verify_aliyun_product_code: 'ID_PRO',
|
|
||||||
user_verify_aliyun_enterprise_flow: '',
|
|
||||||
user_verify_tencent_region: 'ap-guangzhou',
|
|
||||||
user_verify_tencent_personal_flow: '',
|
|
||||||
user_verify_tencent_enterprise_flow: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testing = ref('')
|
const snapshot = computed(() => JSON.stringify(form))
|
||||||
const activeTab = ref('aliyun')
|
const dirty = computed(() => initial.value !== '' && snapshot.value !== initial.value)
|
||||||
const callbackUrls = reactive({ aliyun: '', tencent: '' })
|
|
||||||
const callbackUrlForCurrentProvider = computed(() => callbackUrls[identityForm.provider] || '')
|
|
||||||
const initialSnapshot = ref('')
|
|
||||||
const states = reactive({ aliyun: {}, tencent: {} })
|
|
||||||
const verifyForm = reactive({ ...verifyDefaults })
|
|
||||||
const identityForm = reactive({ provider: 'aliyun' })
|
|
||||||
const credentials = reactive({ aliyun: {}, tencent: {} })
|
|
||||||
const clearCredentials = reactive({ aliyun: false, tencent: false })
|
|
||||||
|
|
||||||
const snapshot = computed(() => JSON.stringify({ verifyForm, identityForm, credentials, clearCredentials }))
|
|
||||||
const dirty = computed(() => initialSnapshot.value !== '' && snapshot.value !== initialSnapshot.value)
|
|
||||||
watch(() => identityForm.provider, value => { activeTab.value = value })
|
|
||||||
|
|
||||||
function providerLabel(key) {
|
|
||||||
return key === 'aliyun' ? '阿里云' : '腾讯云'
|
|
||||||
}
|
|
||||||
function providerState(key) {
|
|
||||||
return states[key] || {}
|
|
||||||
}
|
|
||||||
function credentialPlaceholder(provider, field) {
|
|
||||||
return providerState(provider).configured_fields?.[field] ? '已配置,留空则保留' : '尚未配置'
|
|
||||||
}
|
|
||||||
function extractData(response) {
|
function extractData(response) {
|
||||||
return response?.data ?? response ?? {}
|
return response?.data ?? response ?? {}
|
||||||
}
|
}
|
||||||
@@ -222,123 +54,27 @@ function extractData(response) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [verifyResponse, identityResponse] = await Promise.all([
|
Object.assign(form, defaults, extractData(await api.getSchemaPage('users', 'verify')))
|
||||||
api.getSchemaPage('users', 'verify'),
|
initial.value = snapshot.value
|
||||||
api.getIdentitySettings(),
|
|
||||||
])
|
|
||||||
Object.assign(verifyForm, verifyDefaults, extractData(verifyResponse))
|
|
||||||
const identity = extractData(identityResponse)
|
|
||||||
identityForm.provider = providerKeys.includes(identity.provider) ? identity.provider : 'aliyun'
|
|
||||||
Object.assign(callbackUrls, identity.callback_url || {})
|
|
||||||
providerKeys.forEach(key => {
|
|
||||||
states[key] = identity.providers?.[key] || {}
|
|
||||||
credentials[key] = {}
|
|
||||||
clearCredentials[key] = false
|
|
||||||
})
|
|
||||||
activeTab.value = identityForm.provider
|
|
||||||
initialSnapshot.value = snapshot.value
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || '加载身份认证配置失败')
|
message.error(error.message || '加载身份认证设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderPayload(key) {
|
|
||||||
const replacement = {}
|
|
||||||
credentialFields[key].forEach(({ key: field }) => {
|
|
||||||
const value = String(credentials[key]?.[field] || '').trim()
|
|
||||||
if (value) replacement[field] = value
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
credentials: replacement,
|
|
||||||
clear_credentials: clearCredentials[key],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persist() {
|
|
||||||
const [verifyResponse, identityResponse] = await Promise.all([
|
|
||||||
api.saveSchemaPage('users', 'verify', { ...verifyForm }),
|
|
||||||
api.saveIdentitySettings({
|
|
||||||
provider: identityForm.provider,
|
|
||||||
providers: {
|
|
||||||
aliyun: buildProviderPayload('aliyun'),
|
|
||||||
tencent: buildProviderPayload('tencent'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
const identity = extractData(identityResponse)
|
|
||||||
Object.assign(verifyForm, extractData(verifyResponse))
|
|
||||||
providerKeys.forEach(key => {
|
|
||||||
states[key] = identity.providers?.[key] || states[key]
|
|
||||||
credentials[key] = {}
|
|
||||||
clearCredentials[key] = false
|
|
||||||
})
|
|
||||||
Object.assign(callbackUrls, identity.callback_url || callbackUrls)
|
|
||||||
initialSnapshot.value = snapshot.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const hasClear = providerKeys.some(key => clearCredentials[key])
|
saving.value = true
|
||||||
const run = async () => {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await persist()
|
|
||||||
message.success('身份认证配置已保存')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasClear) return run()
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认清除凭证',
|
|
||||||
content: '已勾选的服务商凭证将被永久删除,是否继续保存?',
|
|
||||||
positiveText: '清除并保存',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: run,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testProvider(provider) {
|
|
||||||
testing.value = provider
|
|
||||||
try {
|
try {
|
||||||
const response = await api.testIdentitySettings({ provider })
|
Object.assign(form, defaults, extractData(await api.saveSchemaPage('users', 'verify', { ...form })))
|
||||||
message.success(response?.message || `${providerLabel(provider)}配置测试通过`)
|
initial.value = snapshot.value
|
||||||
|
message.success('身份认证设置已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || `${providerLabel(provider)}配置测试失败`)
|
message.error(error.message || '保存身份认证设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = ''
|
saving.value = false
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyCallback() {
|
|
||||||
if (!callbackUrlForCurrentProvider.value) return message.warning('回调地址暂不可用')
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(callbackUrlForCurrentProvider.value)
|
|
||||||
message.success('回调地址已复制')
|
|
||||||
} catch {
|
|
||||||
message.error('复制失败,请手动复制')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-status-card :deep(.n-card__content) {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.aksrt-section-card { margin-top: 16px; }
|
|
||||||
.aksrt-provider-tabs { margin-top: 16px; }
|
|
||||||
.aksrt-form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
|
|
||||||
<n-drawer-content title="举报详情" closable>
|
|
||||||
<n-spin :show="loading">
|
|
||||||
<template v-if="detail.report">
|
|
||||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
|
||||||
<n-descriptions-item label="举报人">
|
|
||||||
{{ detail.report.reporter?.display_name || `用户 #${detail.report.reporter?.id || 0}` }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="举报目标">
|
|
||||||
{{ targetTitle }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="目标类型">
|
|
||||||
{{ detail.report.target_type }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="分类">
|
|
||||||
{{ detail.report.category || '-' }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="当前状态">
|
|
||||||
{{ detail.report.status }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="提交时间">
|
|
||||||
{{ detail.report.created_at || '-' }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">证据链</div>
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
{{ detail.report.reason || '暂无举报原因' }}
|
|
||||||
</n-alert>
|
|
||||||
<n-space class="aksrt-report-drawer__meta" wrap>
|
|
||||||
<n-tag size="small" round :bordered="false">
|
|
||||||
目标 ID:{{ detail.report.target_id }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag
|
|
||||||
v-if="detail.report.target?.status"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
round
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
目标状态:{{ detail.report.target.status }}
|
|
||||||
</n-tag>
|
|
||||||
<a
|
|
||||||
v-if="detail.report.target?.url"
|
|
||||||
:href="detail.report.target.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="aksrt-report-drawer__link"
|
|
||||||
>
|
|
||||||
打开目标内容
|
|
||||||
</a>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">处理记录</div>
|
|
||||||
<div v-if="detail.report.notes?.length" class="aksrt-report-drawer__timeline">
|
|
||||||
<div v-for="(entry, index) in detail.report.notes" :key="index" class="aksrt-report-drawer__timeline-item">
|
|
||||||
<div class="aksrt-report-drawer__timeline-head">
|
|
||||||
<strong>{{ entry.status || '-' }}</strong>
|
|
||||||
<span>{{ entry.timestamp || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<p>{{ entry.note || '无备注' }}</p>
|
|
||||||
<small>{{ entry.operator_name || '系统' }} {{ entry.effect || '' }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-empty v-else description="暂无处理记录" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-report-drawer__section">
|
|
||||||
<div class="aksrt-report-drawer__title">处理工单</div>
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
|
|
||||||
<n-select v-model:value="form.action_taken" :options="detail.options?.actions || []" />
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.note"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
placeholder="记录处理依据、证据补充或处罚说明"
|
|
||||||
/>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" :loading="submitting" @click="handleSubmit">提交处理</n-button>
|
|
||||||
<n-button @click="loadDetail">刷新详情</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-empty v-else description="暂无举报详情" />
|
|
||||||
</n-spin>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: { type: Boolean, default: false },
|
|
||||||
reportId: { type: [Number, String], default: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'updated'])
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const detail = ref({ report: null, options: { statuses: [], actions: [] } })
|
|
||||||
const form = reactive({
|
|
||||||
status: 'reviewing',
|
|
||||||
action_taken: 'none',
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const targetTitle = computed(() => {
|
|
||||||
const target = detail.value?.report?.target
|
|
||||||
if (!target) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
return target.title || target.display_name || target.excerpt || `目标 #${target.id || 0}`
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.show, props.reportId],
|
|
||||||
async ([visible, id]) => {
|
|
||||||
if (visible && id) {
|
|
||||||
await loadDetail()
|
|
||||||
}
|
|
||||||
if (!visible) {
|
|
||||||
detail.value = { report: null, options: { statuses: [], actions: [] } }
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
form.status = 'reviewing'
|
|
||||||
form.action_taken = 'none'
|
|
||||||
form.note = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail() {
|
|
||||||
if (!props.reportId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request(`/users/reports/${props.reportId}`)
|
|
||||||
detail.value = res?.data || { report: null, options: { statuses: [], actions: [] } }
|
|
||||||
form.status = detail.value?.report?.status || 'reviewing'
|
|
||||||
form.action_taken = 'none'
|
|
||||||
form.note = ''
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载举报详情失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!props.reportId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await api.request(`/users/reports/${props.reportId}/process`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
status: form.status,
|
|
||||||
action_taken: form.action_taken,
|
|
||||||
note: form.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('举报工单已处理')
|
|
||||||
await loadDetail()
|
|
||||||
emit('updated')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '处理失败')
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-report-drawer__section {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__title {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__meta {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__link {
|
|
||||||
color: #2563eb;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item p {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
color: #111827;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-report-drawer__timeline-item small {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,7 +7,6 @@ export const corePageLoaders = {
|
|||||||
PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'),
|
PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'),
|
||||||
CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'),
|
CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'),
|
||||||
PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'),
|
PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'),
|
||||||
AttachmentPermissions: () => import('@/pages/permissions/AttachmentPermissions.vue'),
|
|
||||||
CmsCredits: () => import('@/pages/CmsCredits.vue'),
|
CmsCredits: () => import('@/pages/CmsCredits.vue'),
|
||||||
ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'),
|
ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'),
|
||||||
ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'),
|
ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'),
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const extensionsPageLoaders = {
|
|
||||||
ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'),
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import { corePageLoaders } from './core'
|
import { corePageLoaders } from './core'
|
||||||
import { usersPageLoaders } from './users'
|
import { usersPageLoaders } from './users'
|
||||||
import { paymentPageLoaders } from './payment'
|
|
||||||
import { toolsPageLoaders } from './tools'
|
import { toolsPageLoaders } from './tools'
|
||||||
import { extensionsPageLoaders } from './extensions'
|
|
||||||
|
|
||||||
export const componentLoaders = {
|
export const componentLoaders = {
|
||||||
...corePageLoaders,
|
...corePageLoaders,
|
||||||
...usersPageLoaders,
|
...usersPageLoaders,
|
||||||
...paymentPageLoaders,
|
|
||||||
...toolsPageLoaders,
|
...toolsPageLoaders,
|
||||||
...extensionsPageLoaders,
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const paymentPageLoaders = {
|
|
||||||
PaymentGateway: () => import('@/pages/payment/PaymentGateway.vue'),
|
|
||||||
}
|
|
||||||
@@ -72,16 +72,13 @@ function createSectionRedirectRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Old SPA ops routes → native WP admin pages (Phase 4 transition).
|
* Old SPA ops routes → native WP admin pages.
|
||||||
* Bookmarks under order-related paths land on NativeOpsRedirect.
|
* Bookmarks under order-related paths land on NativeOpsRedirect.
|
||||||
|
* (封禁/举报 as a sidebar entry lives in constants/sections/users.js → /users/ban.)
|
||||||
*/
|
*/
|
||||||
const NATIVE_OPS_REDIRECTS = [
|
const NATIVE_OPS_REDIRECTS = [
|
||||||
{ path: '/payment/charge-card', title: '充值卡', nativeAdminPath: 'admin.php?page=aksrt_charge_cards' },
|
|
||||||
{ path: '/payment/income', title: '创作分成运营', nativeAdminPath: 'admin.php?page=aksrt_income' },
|
|
||||||
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
|
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
|
||||||
{ path: '/users/referral-codes', title: '推广码运营', nativeAdminPath: 'users.php?page=aksrt_referral_codes' },
|
{ path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban' },
|
||||||
{ path: '/users/ban', title: '禁封管理', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=restrictions' },
|
|
||||||
{ path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=reports' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
|||||||
@@ -43,17 +43,6 @@ export function normalizeSchemaDomain(domain = '') {
|
|||||||
return value.replace(/-/g, '_')
|
return value.replace(/-/g, '_')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CANONICAL_SCHEMA_DOMAINS = [
|
|
||||||
'global',
|
|
||||||
'page_display',
|
|
||||||
'post_list',
|
|
||||||
'permissions',
|
|
||||||
'users',
|
|
||||||
'payment',
|
|
||||||
'extensions',
|
|
||||||
'tools',
|
|
||||||
]
|
|
||||||
|
|
||||||
/** All schemas organized by domain */
|
/** All schemas organized by domain */
|
||||||
export const ALL_SCHEMAS = Object.freeze({
|
export const ALL_SCHEMAS = Object.freeze({
|
||||||
global: globalSchemas,
|
global: globalSchemas,
|
||||||
@@ -77,31 +66,3 @@ export function getSchema(domain, section) {
|
|||||||
if (!domainSchemas) return null
|
if (!domainSchemas) return null
|
||||||
return domainSchemas[section] || null
|
return domainSchemas[section] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all schemas for a domain.
|
|
||||||
* @param {string} domain
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
export function getDomainSchemas(domain) {
|
|
||||||
return ALL_SCHEMAS[normalizeSchemaDomain(domain)] || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSchemaSectionKeys(domain) {
|
|
||||||
return Object.keys(getDomainSchemas(domain))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSchemaDefaults(domain, section = '') {
|
|
||||||
const domainKey = normalizeSchemaDomain(domain)
|
|
||||||
|
|
||||||
if (section) {
|
|
||||||
return { ...(getSchema(domainKey, section)?.defaults || {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(getDomainSchemas(domainKey)).reduce((defaults, schema) => {
|
|
||||||
return {
|
|
||||||
...defaults,
|
|
||||||
...(schema?.defaults || {}),
|
|
||||||
}
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
* Each key corresponds to a sub-page under the "扩展&增强" section.
|
* Each key corresponds to a sub-page under the "扩展&增强" section.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { systemSchema } from './system.js'
|
||||||
import { ipSchema } from './ip.js'
|
import { ipSchema } from './ip.js'
|
||||||
import { auditSchema } from './audit.js'
|
import { auditSchema } from './audit.js'
|
||||||
import { pushSchema } from './push.js'
|
import { pushSchema } from './push.js'
|
||||||
|
import { securitySchema } from './security.js'
|
||||||
|
|
||||||
export const extensionsSchemas = {
|
export const extensionsSchemas = {
|
||||||
|
system: systemSchema,
|
||||||
ip: ipSchema,
|
ip: ipSchema,
|
||||||
audit: auditSchema,
|
audit: auditSchema,
|
||||||
push: pushSchema,
|
push: pushSchema,
|
||||||
|
security: securitySchema,
|
||||||
}
|
}
|
||||||
@@ -2,21 +2,16 @@ export const pushSchema = {
|
|||||||
key: 'extensions.push',
|
key: 'extensions.push',
|
||||||
title: '百度推送',
|
title: '百度推送',
|
||||||
icon: 'fa fas fa-paper-plane',
|
icon: 'fa fas fa-paper-plane',
|
||||||
description: '配置搜索引擎主动推送、批量提交和状态追踪。',
|
description: '配置百度主动推送、收录通道和推送状态。',
|
||||||
defaults: {
|
defaults: {
|
||||||
baidu_push_enabled: false,
|
baidu_push_enabled: false,
|
||||||
baidu_push_token: '',
|
baidu_push_token: '',
|
||||||
baidu_push_auto: true,
|
baidu_push_auto: true,
|
||||||
baidu_push_on_publish: true,
|
baidu_push_on_publish: true,
|
||||||
baidu_push_on_update: false,
|
baidu_push_on_update: false,
|
||||||
baidu_push_batch_size: 50,
|
baidu_push_normal: true,
|
||||||
|
baidu_push_fast: false,
|
||||||
baidu_push_log_enabled: true,
|
baidu_push_log_enabled: true,
|
||||||
xzh_on: false,
|
|
||||||
xzh_appid: '',
|
|
||||||
xzh_render_tail: true,
|
|
||||||
xzh_jsonld_single: true,
|
|
||||||
xzh_jsonld_page: false,
|
|
||||||
xzh_jsonld_img: false,
|
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
@@ -25,7 +20,7 @@ export const pushSchema = {
|
|||||||
icon: 'fa fas fa-rocket',
|
icon: 'fa fas fa-rocket',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
|
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
|
||||||
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' },
|
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token(仅填 Token,勿填完整链接),并确保平台中的站点为本站' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,21 +29,17 @@ export const pushSchema = {
|
|||||||
icon: 'fa fas fa-bolt',
|
icon: 'fa fas fa-bolt',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
|
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
|
||||||
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '文章 / 分类首次发布时推送链接;推送成功后会记录,不再重复提交' },
|
||||||
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '已发布内容再次保存时,若上次推送未成功则重试;需主动再次提交请在编辑页勾选「重新提交」' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'xzh',
|
key: 'channel',
|
||||||
title: '熊掌号 / JSON-LD',
|
title: '收录通道',
|
||||||
icon: 'fa fas fa-paw',
|
icon: 'fa fas fa-inbox',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false },
|
{ key: 'baidu_push_normal', label: '普通收录', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '每天可提交最多10万条有价值内容,收录速度较慢' },
|
||||||
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } },
|
{ key: 'baidu_push_fast', label: '快速收录', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '百度新推出的高效收录接口,目前仅对部分优质站点开放,请确保您的站点已开放快速收录功能' },
|
||||||
{ key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
{ key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,8 +47,7 @@ export const pushSchema = {
|
|||||||
title: '高级设置',
|
title: '高级设置',
|
||||||
icon: 'fa fas fa-cog',
|
icon: 'fa fas fa-cog',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: 'WP_DEBUG 开启时,将推送结果写入错误日志' },
|
||||||
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export const securitySchema = {
|
||||||
|
key: 'extensions.security',
|
||||||
|
title: '网站安全',
|
||||||
|
icon: 'fa fas fa-umbrella',
|
||||||
|
description: '防刷限制、验证码发送限制与站点安全加固。',
|
||||||
|
defaults: {
|
||||||
|
security_brush_post_10min: 20,
|
||||||
|
security_brush_post_day: 50,
|
||||||
|
security_brush_comment_10min: 20,
|
||||||
|
security_brush_comment_day: 100,
|
||||||
|
security_brush_upload_10min: 50,
|
||||||
|
security_brush_upload_day: 200,
|
||||||
|
security_brush_register_10min: 20,
|
||||||
|
security_brush_register_day: 100,
|
||||||
|
security_brush_order_10min: 30,
|
||||||
|
security_brush_order_day: 100,
|
||||||
|
security_captcha_send_cooldown: 60,
|
||||||
|
security_captcha_to_hour: 15,
|
||||||
|
security_captcha_to_day: 30,
|
||||||
|
security_captcha_ip_10min: 20,
|
||||||
|
security_captcha_ip_day: 50,
|
||||||
|
security_error_frequency_limit: true,
|
||||||
|
security_disabled_pingback: true,
|
||||||
|
security_disabled_xmlrpc: true,
|
||||||
|
security_user_disable_admin: true,
|
||||||
|
close_attachment_page: true,
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'brush_post',
|
||||||
|
title: '[防刷机制]发布限制',
|
||||||
|
icon: 'fa fas fa-pen',
|
||||||
|
description: '限制单个用户发布文章、帖子、板块的总数量(为0则不限制)。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_brush_post_10min', label: '10分钟内最多发布 (篇)', type: 'number', default: 20, min: 0, max: 200 },
|
||||||
|
{ key: 'security_brush_post_day', label: '一天内最多发布 (篇)', type: 'number', default: 50, min: 0, max: 2000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brush_comment',
|
||||||
|
title: '[防刷机制]评论限制',
|
||||||
|
icon: 'fa fas fa-comments',
|
||||||
|
description: '限制单个用户发布评论的总数量(为0则不限制)。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_brush_comment_10min', label: '10分钟内最多评论 (条)', type: 'number', default: 20, min: 0, max: 200 },
|
||||||
|
{ key: 'security_brush_comment_day', label: '一天内最多评论 (条)', type: 'number', default: 100, min: 0, max: 2000, help: '如果启用了免登陆可评论,则根据用户IP地址判断' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brush_upload',
|
||||||
|
title: '[防刷机制]上传限制',
|
||||||
|
icon: 'fa fas fa-upload',
|
||||||
|
description: '限制单个用户上传内容的总数量(为0则不限制)。防刷机制主要为了防止用户恶意多次提交内容,请根据网站的用户量合理设置,此机制不会限制管理员。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_brush_upload_10min', label: '10分钟内最多上传 (个)', type: 'number', default: 50, min: 0, max: 200 },
|
||||||
|
{ key: 'security_brush_upload_day', label: '一天内最多上传 (个)', type: 'number', default: 200, min: 0, max: 2000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brush_register',
|
||||||
|
title: '[防刷机制]注册限制',
|
||||||
|
icon: 'fa fas fa-user-plus',
|
||||||
|
description: '限制每个IP地址用户注册数量,此项无法判断管理员身份,所以管理员也会受到限制。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_brush_register_10min', label: '10分钟内最多注册 (个)', type: 'number', default: 20, min: 0, max: 200 },
|
||||||
|
{ key: 'security_brush_register_day', label: '一天内最多注册 (个)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brush_order',
|
||||||
|
title: '[防刷机制]下单限制',
|
||||||
|
icon: 'fa fas fa-shopping-cart',
|
||||||
|
description: '限制每个用户下单数量,未登录用户按IP地址判断。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_brush_order_10min', label: '10分钟内最多下单 (个订单)', type: 'number', default: 30, min: 0, max: 200 },
|
||||||
|
{ key: 'security_brush_order_day', label: '一天内最多下单 (个订单)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brush_captcha',
|
||||||
|
title: '[防刷机制]验证码发送限制',
|
||||||
|
icon: 'fa fas fa-paper-plane',
|
||||||
|
description: '限制验证码邮件/短信的发送频率,防止恶意程序批量轰炸特定邮箱或手机号。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_captcha_send_cooldown', label: '同一收件人/IP发送冷却 (秒)', type: 'number', default: 60, min: 0, max: 600, help: '同一邮箱或手机号、同一IP地址发送验证码后,需间隔此时间才可再次发送(0为不限制)' },
|
||||||
|
{ key: 'security_captcha_to_hour', label: '同一收件人1小时内最多 (次)', type: 'number', default: 15, min: 0, max: 500 },
|
||||||
|
{ key: 'security_captcha_to_day', label: '同一收件人一天内最多 (次)', type: 'number', default: 30, min: 0, max: 1000 },
|
||||||
|
{ key: 'security_captcha_ip_10min', label: '同一IP 10分钟内最多 (次)', type: 'number', default: 20, min: 0, max: 500 },
|
||||||
|
{ key: 'security_captcha_ip_day', label: '同一IP一天内最多 (次)', type: 'number', default: 50, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'harden',
|
||||||
|
title: '安全加固',
|
||||||
|
icon: 'fa fas fa-shield-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'security_error_frequency_limit', label: '错误频率限制', type: 'switch', default: true, help: '用户进行密码、邮箱、手机号、验证码等验证时,验证错误次数超过5次后限制30秒验证一次,错误超过10次后,限制3分钟验证一次,有效防止暴力破解' },
|
||||||
|
{ key: 'security_disabled_pingback', label: '防pingback攻击', type: 'switch', default: true },
|
||||||
|
{ key: 'security_disabled_xmlrpc', label: '禁用XML-RPC', type: 'switch', default: true, help: '如果无需使用xmlrpc功能,建议直接删除WordPress目录下的xmlrpc.php文件' },
|
||||||
|
{ key: 'security_user_disable_admin', label: '非管理员禁止进入后台', type: 'switch', default: true, help: '为了安全,如非必要请勿允许非管理员进入后台' },
|
||||||
|
{ key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', default: true, help: '非管理员前台不显示附件(图片、文件等)页面,直接显示404,能有效提高文件的安全性' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export const systemSchema = {
|
||||||
|
key: 'extensions.system',
|
||||||
|
title: '系统工具',
|
||||||
|
icon: 'fa fas fa-gavel',
|
||||||
|
description: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
|
||||||
|
defaults: {
|
||||||
|
hide_admin_bar: true,
|
||||||
|
remove_emoji: true,
|
||||||
|
remove_open_sans: true,
|
||||||
|
remove_more_wp_head: true,
|
||||||
|
remove_dashboard_widgets: true,
|
||||||
|
display_wp_update: true,
|
||||||
|
newfilename: false,
|
||||||
|
newfilename_type: 'time',
|
||||||
|
split_upload_s: true,
|
||||||
|
split_minimum_size: 20,
|
||||||
|
disabled_large_thumbnail: true,
|
||||||
|
admin_user_del_fields: true,
|
||||||
|
admin_frontend_set: true,
|
||||||
|
close_gutenberg: false,
|
||||||
|
disabled_autoembed: true,
|
||||||
|
mce_float_toolbar: ['pc_s', 'm_s'],
|
||||||
|
time_ago_s: true,
|
||||||
|
time_format: 'n月j日 H:i',
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'optimize',
|
||||||
|
title: 'WordPress 优化',
|
||||||
|
icon: 'fa fas fa-bolt',
|
||||||
|
description: '精简WordPress自带的冗余功能,提升前台与后台的加载速度。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'hide_admin_bar', label: '前台关闭顶部admin_bar', type: 'switch', default: true, help: '开启后前台不显示WordPress顶部黑条' },
|
||||||
|
{ key: 'remove_emoji', label: '删除WordPress自带Emoji表情', type: 'switch', default: true },
|
||||||
|
{ key: 'remove_open_sans', label: '禁用Google字体', type: 'switch', default: true },
|
||||||
|
{ key: 'remove_more_wp_head', label: '清理多余的头部Meta标签', type: 'switch', default: true },
|
||||||
|
{ key: 'remove_dashboard_widgets', label: 'WordPress 后台加速', type: 'switch', default: true, help: '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度' },
|
||||||
|
{ key: 'display_wp_update', label: '禁止WordPress检测更新', type: 'switch', default: true, help: '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
title: '上传优化',
|
||||||
|
icon: 'fa fas fa-cloud-upload-alt',
|
||||||
|
description: '上传文件的命名与缩略图裁剪优化,减少硬盘占用并提高上传效率。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'split_upload_s', label: '大文件分片上传', type: 'switch', default: true, help: '分片上传大文件,优化大文件上传效率(支持断点续传)' },
|
||||||
|
{ key: 'split_minimum_size', label: '分片上传最小文件', type: 'number', default: 20, min: 4, max: 100, unit: 'M', dependsOn: { key: 'split_upload_s', value: true }, help: '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件' },
|
||||||
|
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', default: false, help: '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率' },
|
||||||
|
{
|
||||||
|
key: 'newfilename_type',
|
||||||
|
label: '重命名格式',
|
||||||
|
type: 'radio',
|
||||||
|
buttonStyle: false,
|
||||||
|
default: 'time',
|
||||||
|
dependsOn: { key: 'newfilename', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: '随机字串符', value: 'random' },
|
||||||
|
{ label: '时间+原文件名', value: 'time' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'disabled_large_thumbnail', label: '禁止WP大尺寸缩略图裁剪', type: 'switch', default: true, help: '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'editor',
|
||||||
|
title: '编辑器与显示',
|
||||||
|
icon: 'fa fas fa-edit',
|
||||||
|
description: '编辑器模式、用户资料页与前台时间显示格式的定制。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'admin_user_del_fields', label: '关闭后台用户编辑多余的选项', type: 'switch', default: true, help: '开启后在后台编辑用户资料时将不显示无用的多余选项' },
|
||||||
|
{ key: 'admin_frontend_set', label: '前端页面编辑', type: 'switch', default: true, help: '管理员可在前台快速修改页面、文章参数' },
|
||||||
|
{ key: 'close_gutenberg', label: '禁用古腾堡编辑器', type: 'switch', default: false, help: '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!' },
|
||||||
|
{ key: 'disabled_autoembed', label: '禁用Auto Embed', type: 'switch', default: true, help: 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度' },
|
||||||
|
{
|
||||||
|
key: 'mce_float_toolbar',
|
||||||
|
label: '经典编辑器增强浮动工具栏',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s', 'm_s'],
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
help: '开启后前端编辑器会显示浮动的工具栏,更加方便书写',
|
||||||
|
},
|
||||||
|
{ key: 'time_ago_s', label: '倒计时显示', type: 'switch', default: true, help: '时间格式化为:X分钟前,X小时前,X天前....' },
|
||||||
|
{ key: 'time_format', label: '自定义时间格式', type: 'input', default: 'n月j日 H:i', dependsOn: { key: 'time_ago_s', operator: 'falsy' }, help: '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
export const emailSchema = {
|
|
||||||
key: 'global.email',
|
|
||||||
title: '邮件配置',
|
|
||||||
icon: 'fa fas fa-envelope',
|
|
||||||
description: 'SMTP 发信配置、邮件事件开关与模板自定义。',
|
|
||||||
defaults: {
|
|
||||||
email_footer_text: '',
|
|
||||||
email_site_branding: true,
|
|
||||||
email_template_mode: 'plain',
|
|
||||||
email_reg_verify_link_enabled: true,
|
|
||||||
email_reg_verify_code_enabled: true,
|
|
||||||
email_reg_success_enabled: true,
|
|
||||||
email_password_reset_enabled: true,
|
|
||||||
email_change_code_enabled: true,
|
|
||||||
email_change_notice_enabled: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'global',
|
|
||||||
title: '全局设置',
|
|
||||||
icon: 'fa fas fa-cog',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_footer_text', label: '邮件页脚文字', type: 'textarea', rows: 2, placeholder: '留空则使用站点名称', help: '附加在每封邮件底部的文字' },
|
|
||||||
{ key: 'email_site_branding', label: '显示站点品牌', type: 'switch', default: true, help: '在邮件开头显示「[站点名]」前缀' },
|
|
||||||
{ key: 'email_template_mode', label: '邮件格式', type: 'radio', default: 'plain', options: [
|
|
||||||
{ label: '纯文本', value: 'plain' },
|
|
||||||
{ label: 'HTML', value: 'html' },
|
|
||||||
]},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'branding',
|
|
||||||
title: '邮件品牌',
|
|
||||||
icon: 'fa fas fa-pen',
|
|
||||||
fields: [
|
|
||||||
{ key: 'mail_showname', label: '发件人名称', type: 'input', default: '', help: '覆盖系统默认发件人名称' },
|
|
||||||
{ key: 'mail_title_prefix', label: '主题前缀', type: 'input', default: '', help: '自动添加在所有邮件主题前面,如【站点名】' },
|
|
||||||
{ key: 'mail_description', label: '邮件底部说明', type: 'textarea', rows: 3, default: '', help: '附加在邮件页脚文字之后,留空不显示' },
|
|
||||||
{ key: 'mail_more_content', label: '邮件附加内容', type: 'textarea', rows: 4, default: '', help: '附加在邮件内容末尾的补充说明' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_verify_link',
|
|
||||||
title: '注册验证链接',
|
|
||||||
icon: 'fa fas fa-link',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_verify_link_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送验证链接邮件' },
|
|
||||||
{ key: 'email_reg_verify_link_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '验证你的邮箱地址' },
|
|
||||||
{ key: 'email_reg_verify_link_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 验证链接' },
|
|
||||||
{ key: 'email_reg_verify_link_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_verify_code',
|
|
||||||
title: '注册验证码',
|
|
||||||
icon: 'fa fas fa-keyboard',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_verify_code_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送 6 位验证码邮件' },
|
|
||||||
{ key: 'email_reg_verify_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
|
||||||
{ key: 'email_reg_verify_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
|
||||||
{ key: 'email_reg_verify_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reg_success',
|
|
||||||
title: '注册成功通知',
|
|
||||||
icon: 'fa fas fa-check-circle',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_reg_success_enabled', label: '启用', type: 'switch', default: true, help: '邮箱验证成功后发送通知邮件' },
|
|
||||||
{ key: 'email_reg_success_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证成功' },
|
|
||||||
{ key: 'email_reg_success_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name}', help: '可用占位符:{username} 用户名、{site_name} 站点名' },
|
|
||||||
{ key: 'email_reg_success_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password_reset',
|
|
||||||
title: '密码重置',
|
|
||||||
icon: 'fa fas fa-unlock-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_password_reset_enabled', label: '启用', type: 'switch', default: true, help: '发送密码重置链接邮件' },
|
|
||||||
{ key: 'email_password_reset_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '重置密码' },
|
|
||||||
{ key: 'email_password_reset_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 重置链接' },
|
|
||||||
{ key: 'email_password_reset_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change_code',
|
|
||||||
title: '邮箱变更验证码',
|
|
||||||
icon: 'fa fas fa-shield-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_change_code_enabled', label: '启用', type: 'switch', default: true, help: '变更邮箱时发送验证码邮件' },
|
|
||||||
{ key: 'email_change_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
|
||||||
{ key: 'email_change_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
|
||||||
{ key: 'email_change_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'change_notice',
|
|
||||||
title: '邮箱变更通知',
|
|
||||||
icon: 'fa fas fa-bell',
|
|
||||||
fields: [
|
|
||||||
{ key: 'email_change_notice_enabled', label: '启用', type: 'switch', default: true, help: '邮箱变更成功后向旧邮箱发送通知' },
|
|
||||||
{ key: 'email_change_notice_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '账号邮箱已变更' },
|
|
||||||
{ key: 'email_change_notice_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {new_email}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{new_email} 新邮箱' },
|
|
||||||
{ key: 'email_change_notice_html', label: 'HTML 格式', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const floatingSchema = {
|
|
||||||
key: 'global.floating',
|
|
||||||
title: '右侧悬浮按钮',
|
|
||||||
icon: 'fa fas fa-ellipsis-v',
|
|
||||||
description: '页面右侧浮动按钮的显示与配置。',
|
|
||||||
defaults: {},
|
|
||||||
groups: [],
|
|
||||||
}
|
|
||||||
@@ -7,26 +7,26 @@ import { logoSchema } from './logo.js'
|
|||||||
import { commonSchema } from './common.js'
|
import { commonSchema } from './common.js'
|
||||||
import { layoutSchema } from './layout.js'
|
import { layoutSchema } from './layout.js'
|
||||||
import { searchSchema } from './search.js'
|
import { searchSchema } from './search.js'
|
||||||
import { emailSchema } from './email.js'
|
// Email schema is served by the PHP template catalog to keep defaults and fields canonical.
|
||||||
import { tabbarSchema } from './tabbar.js'
|
import { tabbarSchema } from './tabbar.js'
|
||||||
import { floatingSchema } from './floating.js'
|
|
||||||
import { i18nSchema } from './i18n.js'
|
import { i18nSchema } from './i18n.js'
|
||||||
import { customCodeSchema } from './custom-code.js'
|
import { customCodeSchema } from './custom-code.js'
|
||||||
import { seoSchema } from './seo.js'
|
import { seoSchema } from './seo.js'
|
||||||
import { codeHighlightSchema } from './code-highlight.js'
|
import { codeHighlightSchema } from './code-highlight.js'
|
||||||
import { systemNoticeSchema } from './system-notice.js'
|
import { systemNoticeSchema } from './system-notice.js'
|
||||||
|
import { shareSchema } from './share.js'
|
||||||
|
|
||||||
export const globalSchemas = {
|
export const globalSchemas = {
|
||||||
logo: logoSchema,
|
logo: logoSchema,
|
||||||
common: commonSchema,
|
common: commonSchema,
|
||||||
layout: layoutSchema,
|
layout: layoutSchema,
|
||||||
search: searchSchema,
|
search: searchSchema,
|
||||||
email: emailSchema,
|
email: null,
|
||||||
tabbar: tabbarSchema,
|
tabbar: tabbarSchema,
|
||||||
floating: floatingSchema,
|
|
||||||
i18n: i18nSchema,
|
i18n: i18nSchema,
|
||||||
'custom-code': customCodeSchema,
|
'custom-code': customCodeSchema,
|
||||||
seo: seoSchema,
|
seo: seoSchema,
|
||||||
'code-highlight': codeHighlightSchema,
|
'code-highlight': codeHighlightSchema,
|
||||||
'system-notice': systemNoticeSchema,
|
'system-notice': systemNoticeSchema,
|
||||||
|
share: shareSchema,
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ export const seoSchema = {
|
|||||||
key: 'global.seo',
|
key: 'global.seo',
|
||||||
title: 'SEO 优化',
|
title: 'SEO 优化',
|
||||||
icon: 'fa fas fa-search',
|
icon: 'fa fas fa-search',
|
||||||
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。',
|
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
|
||||||
defaults: {
|
defaults: {
|
||||||
seo_enabled: true,
|
seo_enabled: true,
|
||||||
seo_ai_enabled: false,
|
seo_ai_enabled: false,
|
||||||
@@ -13,10 +13,6 @@ export const seoSchema = {
|
|||||||
ai_seo_title_prompt: '',
|
ai_seo_title_prompt: '',
|
||||||
ai_seo_keywords_prompt: '',
|
ai_seo_keywords_prompt: '',
|
||||||
ai_seo_description_prompt: '',
|
ai_seo_description_prompt: '',
|
||||||
seo_baidu_push: false,
|
|
||||||
seo_baidu_normal: false,
|
|
||||||
seo_baidu_fast: false,
|
|
||||||
seo_baidu_token: '',
|
|
||||||
seo_image_optimize: true,
|
seo_image_optimize: true,
|
||||||
seo_image_default: '',
|
seo_image_default: '',
|
||||||
seo_image_always_default: false,
|
seo_image_always_default: false,
|
||||||
@@ -48,7 +44,7 @@ export const seoSchema = {
|
|||||||
label: '启用 AI SEO 生成',
|
label: '启用 AI SEO 生成',
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
default: false,
|
default: false,
|
||||||
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。',
|
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
|
||||||
dependsOn: { key: 'seo_enabled', value: true },
|
dependsOn: { key: 'seo_enabled', value: true },
|
||||||
},
|
},
|
||||||
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
|
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
|
||||||
@@ -95,17 +91,6 @@ export const seoSchema = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'baidu',
|
|
||||||
title: '百度推送',
|
|
||||||
icon: 'fa fas fa-paper-plane',
|
|
||||||
fields: [
|
|
||||||
{ key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false },
|
|
||||||
{ key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'image',
|
key: 'image',
|
||||||
title: '图片 SEO',
|
title: '图片 SEO',
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const themeRoot = typeof window !== 'undefined' && window.aksrtAdminData?.themeUri
|
||||||
|
? String(window.aksrtAdminData.themeUri).replace(/\/$/, '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const shareOptions = [
|
||||||
|
{ label: 'QQ空间', value: 'qzone', icon: 'fa fab fa-qq' },
|
||||||
|
{ label: '微博', value: 'weibo', icon: 'fa fab fa-weibo' },
|
||||||
|
{ label: 'QQ好友', value: 'qq', icon: 'fa fab fa-qq' },
|
||||||
|
{ label: 'Facebook', value: 'facebook', icon: 'fa fab fa-facebook-f' },
|
||||||
|
{ label: 'X', value: 'twitter', icon: 'fa fab fa-twitter' },
|
||||||
|
{ label: 'Telegram', value: 'telegram', icon: 'fa fab fa-telegram-plane' },
|
||||||
|
{ label: 'WhatsApp', value: 'whatsapp', icon: 'fa fab fa-whatsapp' },
|
||||||
|
{ label: 'LinkedIn', value: 'linkedin', icon: 'fa fab fa-linkedin-in' },
|
||||||
|
{ label: 'Reddit', value: 'reddit', icon: 'fa fab fa-reddit-alien' },
|
||||||
|
{ label: 'LINE', value: 'line', icon: 'fa fab fa-line' },
|
||||||
|
{ label: 'VK', value: 'vk', icon: 'fa fab fa-vk' },
|
||||||
|
{ label: '邮件', value: 'email', icon: 'fa fas fa-envelope' },
|
||||||
|
{ label: '海报分享', value: 'poster', icon: 'fa fas fa-image' },
|
||||||
|
{ label: '复制链接', value: 'copy', icon: 'fa fas fa-link' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultShareItems = {
|
||||||
|
enabled: { qzone: 'QQ空间', weibo: '微博', qq: 'QQ好友', poster: '海报分享', copy: '复制链接' },
|
||||||
|
disabled: { facebook: 'Facebook', twitter: 'X', telegram: 'Telegram', whatsapp: 'WhatsApp', linkedin: 'LinkedIn', reddit: 'Reddit', line: 'LINE', vk: 'VK', email: '邮件' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shareSchema = {
|
||||||
|
key: 'global.share',
|
||||||
|
title: '社交分享',
|
||||||
|
icon: 'fa fas fa-share-alt',
|
||||||
|
description: '配置文章分享平台、微信 JSAPI 分享和海报分享。',
|
||||||
|
defaults: {
|
||||||
|
share_s: true,
|
||||||
|
share_items: defaultShareItems,
|
||||||
|
wechat_share_s: false,
|
||||||
|
wechat_share_option: { appid: '', app_secret: '', only_logo: false },
|
||||||
|
share_img: true,
|
||||||
|
share_img_compatible_s: false,
|
||||||
|
share_img_byimg: themeRoot ? `${themeRoot}/assets/img/slider-bg.jpg` : '',
|
||||||
|
share_logo: themeRoot ? `${themeRoot}/assets/img/logo.png` : '',
|
||||||
|
share_desc: '扫一扫 立即查看',
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'share',
|
||||||
|
title: '社交分享',
|
||||||
|
icon: 'fa fas fa-share-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'share_s', label: '文章分享功能', type: 'switcher', default: true, help: '关闭后,在文章页面将不显示分享按钮。' },
|
||||||
|
{
|
||||||
|
key: 'share_items',
|
||||||
|
label: '分享平台',
|
||||||
|
type: 'custom',
|
||||||
|
renderer: 'sorter',
|
||||||
|
sanitize_type: 'array',
|
||||||
|
default: defaultShareItems,
|
||||||
|
options: shareOptions,
|
||||||
|
description: '左侧启用并拖拽排序,右侧为未启用项(同时启用的数量不建议超过 6 个)。若开启海外平台,用户网络必须能正常访问对应服务。',
|
||||||
|
},
|
||||||
|
{ key: 'wechat_share_s', label: '微信分享有图', type: 'switcher', default: false, help: '使用微信 JSAPI 在微信 App 内分享。' },
|
||||||
|
{
|
||||||
|
key: 'wechat_share_hint',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
dependsOn: { key: 'wechat_share_s', value: true },
|
||||||
|
content: '<p><strong>微信分享有图配置</strong></p><p>此功能用于用户从公众号进入页面后调用微信默认分享能力,并不会额外显示分享按钮。请在公众号后台将当前站点域名配置为 JS 接口安全域名。</p><p><a href="https://mp.weixin.qq.com/" target="_blank" rel="noopener noreferrer">前往微信公众平台</a></p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wechat_share_option',
|
||||||
|
label: '微信分享有图配置',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { appid: '', app_secret: '', only_logo: false },
|
||||||
|
dependsOn: { key: 'wechat_share_s', value: true },
|
||||||
|
fields: [
|
||||||
|
{ key: 'appid', label: 'AppID', type: 'input', default: '' },
|
||||||
|
{ key: 'app_secret', label: 'AppSecret', type: 'input', default: '' },
|
||||||
|
{ key: 'only_logo', label: '全部使用网站图标', type: 'switcher', default: false, help: '默认自动使用页面封面图;开启后所有页面均使用网站图标。图片启用防盗链时建议开启。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'share_img', label: '生成海报分享', type: 'switcher', default: true, help: '网站图片使用 OSS 等云存储时,请先配置允许本站 Canvas 读取图片的跨域规则。' },
|
||||||
|
{
|
||||||
|
key: 'share_img_warning',
|
||||||
|
label: '',
|
||||||
|
type: 'notice',
|
||||||
|
ui_only: true,
|
||||||
|
alertType: 'warning',
|
||||||
|
content: '海报图片加载失败时,请检查图片 CDN/OSS 的跨域响应头。',
|
||||||
|
dependsOn: { key: 'share_img', value: true },
|
||||||
|
},
|
||||||
|
{ key: 'share_img_compatible_s', label: '海报分享兼容模式', type: 'switcher', default: false, dependsOn: { key: 'share_img', value: true }, help: '通过服务端代理读取海报图片,改善跨域兼容性;会增加服务器图片处理开销。' },
|
||||||
|
{ key: 'share_img_byimg', label: '海报分享默认图片', type: 'media', default: themeRoot ? `${themeRoot}/assets/img/slider-bg.jpg` : '', dependsOn: { key: 'share_img', value: true }, help: '文章没有图片时使用,建议尺寸 800×500。' },
|
||||||
|
{ key: 'share_logo', label: '海报分享 LOGO', type: 'media', default: themeRoot ? `${themeRoot}/assets/img/logo.png` : '', dependsOn: { key: 'share_img', value: true }, help: '显示在海报底部,建议尺寸 300×100、大小 50KB 以内。' },
|
||||||
|
{ key: 'share_desc', label: '海报分享底部文案', type: 'input', default: '扫一扫 立即查看', dependsOn: { key: 'share_img', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -124,7 +124,7 @@ export const systemNoticeSchema = {
|
|||||||
},
|
},
|
||||||
{ key: 'system_notice_radius', label: '按钮圆角', type: 'switch', default: false, dependsOn: { key: 'system_notice_enabled', value: true }, help: '开启后弹窗按钮使用圆角样式' },
|
{ key: 'system_notice_radius', label: '按钮圆角', type: 'switch', default: false, dependsOn: { key: 'system_notice_enabled', value: true }, help: '开启后弹窗按钮使用圆角样式' },
|
||||||
{ key: 'system_notice_class', label: '公告样式类', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
{ key: 'system_notice_class', label: '公告样式类', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
||||||
{ key: 'system_notice_title_icon', label: '标题图标', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '如 fa fas fa-bullhorn' },
|
{ key: 'system_notice_title_icon', label: '标题图标', type: 'icon', default: '', dependsOn: { key: 'system_notice_enabled', value: true } },
|
||||||
{ key: 'system_notice_title_class', label: '标题背景配色', type: 'select', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, options: [
|
{ key: 'system_notice_title_class', label: '标题背景配色', type: 'select', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, options: [
|
||||||
{ label: '无', value: '' },
|
{ label: '无', value: '' },
|
||||||
{ label: '炫彩黄', value: 'jb-yellow' },
|
{ label: '炫彩黄', value: 'jb-yellow' },
|
||||||
|
|||||||
@@ -1,38 +1,199 @@
|
|||||||
|
/** 返佣订单类型(与运行时 membership/content/credits 对齐)。 */
|
||||||
|
const rebateTypeOptions = [
|
||||||
|
{ label: '全部订单', value: 'all' },
|
||||||
|
{ label: '购买会员', value: 'membership' },
|
||||||
|
{ label: '付费内容', value: 'content' },
|
||||||
|
{ label: '购买积分', value: 'credits' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推广返佣 — 参考主题结构:开关、识别模式、自己推广自己、按用户类型分档的
|
||||||
|
* 返佣规则(tab)、文案、推广海报与推广让利标签。
|
||||||
|
*/
|
||||||
export const affiliateSchema = {
|
export const affiliateSchema = {
|
||||||
key: 'payment.affiliate',
|
key: 'payment.affiliate',
|
||||||
title: '推广返佣',
|
title: '推广返佣',
|
||||||
icon: 'fa fas fa-share-alt',
|
icon: 'fa fas fa-btc',
|
||||||
description: '配置推广归因、分级佣金比例和结算规则。',
|
description: '推广识别模式、分档返佣规则、返佣文案、推广海报与让利标签。',
|
||||||
defaults: {
|
defaults: {
|
||||||
affiliate_enabled: false,
|
pay_rebate_s: false,
|
||||||
affiliate_ratio_user: 5,
|
pay_rebate_judgment: 'all',
|
||||||
affiliate_ratio_vip_1: 5,
|
pay_rebate_show_users: true,
|
||||||
affiliate_ratio_vip_2: 5,
|
pay_rebate_self_s: true,
|
||||||
affiliate_order_types: ['membership', 'content'],
|
pay_rebate_user_s: [],
|
||||||
affiliate_settlement_days: 7,
|
pay_rebate_ratio: 5,
|
||||||
affiliate_promotion_text: '',
|
pay_rebate_user_s_1: ['all'],
|
||||||
|
pay_rebate_ratio_vip_1: 10,
|
||||||
|
pay_rebate_user_s_2: ['all'],
|
||||||
|
pay_rebate_ratio_vip_2: 20,
|
||||||
|
affiliate_disabled_code_stops_commission: false,
|
||||||
|
referral_attribution_model: 'first_touch',
|
||||||
|
pay_rebate_text_desc: '加入分享计划,获得高额奖励',
|
||||||
|
pay_rebate_text_details_title: '返佣详解',
|
||||||
|
pay_rebate_text_details: '<p>通过您的推广链接打开本站后,在本站购买商品即可获得佣金</p><p>通过您的推广链接注册后的用户今后购买的商品均可获得佣金</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>',
|
||||||
|
pay_rebate_poster_s: true,
|
||||||
|
pay_rebate_poster_img: '',
|
||||||
|
pay_rebate_poster_title: '发现一个精品网站,快来看一看',
|
||||||
|
pay_rebate_poster_desc: '网站有超多精品资源和优惠福利,扫码进入网站还可享受诸多优惠哦',
|
||||||
|
pay_rebate_poster_banner: '',
|
||||||
|
pay_rebate_poster_desc_bottom: '',
|
||||||
|
pay_rebate_text_discount: '推荐购买 下单再减%discount%',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'policy',
|
key: 'notice',
|
||||||
title: '返佣策略',
|
title: '说明',
|
||||||
icon: 'fa fas fa-sitemap',
|
icon: 'fa fas fa-info-circle',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'affiliate_enabled', label: '启用推广返佣', type: 'switch', default: false },
|
{
|
||||||
{ key: 'affiliate_ratio_user', label: '普通会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } },
|
key: 'affiliate_notice',
|
||||||
{ key: 'affiliate_ratio_vip_1', label: '一级会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } },
|
label: '',
|
||||||
{ key: 'affiliate_ratio_vip_2', label: '二级会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } },
|
type: 'content',
|
||||||
{ key: 'affiliate_order_types', label: '适用订单类型', type: 'checkbox', default: ['membership', 'content'], sanitize_type: 'array', options: [{ label: '会员订单', value: 'membership' }, { label: '付费内容', value: 'content' }, { label: '购买积分', value: 'credits' }], dependsOn: { key: 'affiliate_enabled', value: true } },
|
ui_only: true,
|
||||||
{ key: 'affiliate_settlement_days', label: '结算观察期(天)', type: 'number', default: 7, min: 0, max: 90, dependsOn: { key: 'affiliate_enabled', value: true } },
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>识别模式「绑定注册」:用户只要是通过任意推广链接注册的,今后此用户消费均会给推荐人返佣</li><li>识别模式「仅推广链接」:只有通过推广链接购买的才会给推荐人返佣,不会考虑是否是推荐注册的</li><li>开启的订单类型如果一个都不勾选,则代表该用户类型不参与此功能</li><li>您可以根据网站的VIP设置相互配合设置相应的规则</li><li>下面的设置为全局设置,还可以单独为每一个用户设置规则和比例</li></ul>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'switch',
|
||||||
|
title: '推广返佣',
|
||||||
|
icon: 'fa fas fa-toggle-on',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_rebate_s', label: '启用推广返佣', type: 'switch', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mode',
|
||||||
|
title: '推广识别模式',
|
||||||
|
icon: 'fa fas fa-link',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_rebate_judgment',
|
||||||
|
label: '推广识别模式',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'all',
|
||||||
|
dependsOn: { key: 'pay_rebate_s', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: '绑定注册', value: 'all' },
|
||||||
|
{ label: '仅推广链接', value: 'link' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_rebate_show_users', label: '用户中心显示我推荐的用户', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_judgment', value: 'all' } },
|
||||||
|
{ key: 'pay_rebate_self_s', label: '自己推广自己', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_s', value: true }, help: '允许使用自己的推广链接购买商品并获得佣金' },
|
||||||
|
{
|
||||||
|
key: 'referral_attribution_model',
|
||||||
|
label: '推广归因模式',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'first_touch',
|
||||||
|
options: [
|
||||||
|
{ label: '首次触点(首触优先)', value: 'first_touch' },
|
||||||
|
{ label: '最近触点(末触覆盖)', value: 'last_touch' },
|
||||||
|
],
|
||||||
|
help: '首触:访客首次通过推广链接进入后,归因固定且不被后续链接覆盖;末触:最近一次点击的推广码覆盖之前的归因',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rules',
|
||||||
|
title: '返佣规则',
|
||||||
|
icon: 'fa fas fa-table',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'rebate_rule',
|
||||||
|
label: '为不同用户类型设置返佣规则',
|
||||||
|
type: 'tabbed',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'ordinary',
|
||||||
|
title: '普通用户',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_rebate_user_s',
|
||||||
|
label: '返佣订单',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: [],
|
||||||
|
dependsOn: { key: 'pay_rebate_s', value: true },
|
||||||
|
options: rebateTypeOptions,
|
||||||
|
help: '普通用户返利的订单类型,全部关闭,则代表普通用户不参与推广返佣',
|
||||||
|
},
|
||||||
|
{ key: 'pay_rebate_ratio', label: '普通用户返佣比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip1',
|
||||||
|
title: '一级会员',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_rebate_user_s_1',
|
||||||
|
label: '返佣订单',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['all'],
|
||||||
|
dependsOn: { key: 'pay_rebate_s', value: true },
|
||||||
|
options: rebateTypeOptions,
|
||||||
|
help: '一级会员返利的订单类型,全部关闭,则代表一级会员用户不参与推广返佣',
|
||||||
|
},
|
||||||
|
{ key: 'pay_rebate_ratio_vip_1', label: '一级会员返佣比例(%)', type: 'number', default: 10, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip2',
|
||||||
|
title: '二级会员',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_rebate_user_s_2',
|
||||||
|
label: '返佣订单',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['all'],
|
||||||
|
dependsOn: { key: 'pay_rebate_s', value: true },
|
||||||
|
options: rebateTypeOptions,
|
||||||
|
help: '二级会员返利的订单类型,全部关闭,则代表二级会员用户不参与推广返佣',
|
||||||
|
},
|
||||||
|
{ key: 'pay_rebate_ratio_vip_2', label: '二级会员返佣比例(%)', type: 'number', default: 20, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'affiliate_disabled_code_stops_commission', label: '推广码禁用后停止返佣', type: 'switch', default: false, dependsOn: { key: 'pay_rebate_s', value: true }, help: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'text',
|
key: 'text',
|
||||||
title: '文案设置',
|
title: '返佣文案',
|
||||||
icon: 'fa fas fa-pen',
|
icon: 'fa fas fa-file-alt',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'affiliate_promotion_text', label: '推广说明', type: 'textarea', default: '', dependsOn: { key: 'affiliate_enabled', value: true } },
|
{ key: 'pay_rebate_text_desc', label: '返佣文案(一句话简介)', type: 'input', default: '加入分享计划,获得高额奖励', dependsOn: { key: 'pay_rebate_s', value: true } },
|
||||||
|
{ key: 'pay_rebate_text_details_title', label: '返佣详情标题', type: 'input', default: '返佣详解', dependsOn: { key: 'pay_rebate_s', value: true } },
|
||||||
|
{ key: 'pay_rebate_text_details', label: '返佣详情内容', type: 'textarea', sanitize_type: 'html', default: '<p>通过您的推广链接打开本站后,在本站购买商品即可获得佣金</p><p>通过您的推广链接注册后的用户今后购买的商品均可获得佣金</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>', rows: 6, dependsOn: { key: 'pay_rebate_s', value: true }, help: '返佣详情介绍,建议为规则介绍或者其他说明,支持HTML代码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'poster',
|
||||||
|
title: '推广海报',
|
||||||
|
icon: 'fa fas fa-image',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_rebate_poster_s', label: '推广海报', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_s', value: true }, help: '在用户中心显示包含二维码的推广海报' },
|
||||||
|
{ key: 'pay_rebate_poster_img', label: '推广海报顶部图片', type: 'media', default: '', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
|
||||||
|
{ key: 'pay_rebate_poster_title', label: '推广海报标题(20-30个字)', type: 'input', default: '发现一个精品网站,快来看一看', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
|
||||||
|
{ key: 'pay_rebate_poster_desc', label: '推广海报简介(40-60个字)', type: 'input', default: '网站有超多精品资源和优惠福利,扫码进入网站还可享受诸多优惠哦', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
|
||||||
|
{ key: 'pay_rebate_poster_banner', label: '海报顶部备用图片', type: 'media', default: '', dependsOn: { key: 'pay_rebate_poster_s', value: true }, help: '顶部主图加载失败时依次回退到这里,再回退到主题内置图,避免海报顶部出现空白。' },
|
||||||
|
{ key: 'pay_rebate_poster_desc_bottom', label: '海报底部文案', type: 'input', default: '扫描二维码阅读全文', dependsOn: { key: 'pay_rebate_poster_s', value: true }, help: '画在二维码下方居中位置,留空则使用默认文案。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discount',
|
||||||
|
title: '推广让利标签',
|
||||||
|
icon: 'fa fas fa-tags',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'discount_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
dependsOn: { key: 'pay_rebate_s', value: true },
|
||||||
|
content: '<p><b>推广让利标签配置详解:</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>可使用的变量:折扣 <code>%discount%</code>、推荐人姓名 <code>%referrer_name%</code></li><li>使用变量示例:<code>%referrer_name%推荐购买 下单再减%discount%元</code></li></ul>',
|
||||||
|
},
|
||||||
|
{ key: 'pay_rebate_text_discount', label: '推广让利标签', type: 'input', default: '推荐购买 下单再减%discount%', dependsOn: { key: 'pay_rebate_s', value: true }, help: '显示在购买模块,推广让利的标签文案' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 售后策略 — 售后开关、申请时效、超时效提示与申请表单配置。
|
||||||
|
*
|
||||||
|
* 字段键、顺序、类型与默认值需与 src/Admin/Schema/SchemaPayment.php 的
|
||||||
|
* after_sale 分区保持 1:1(tests/widget-system/after-sale-policy-contract.test.cjs 守护)。
|
||||||
|
*/
|
||||||
|
export const afterSaleSchema = {
|
||||||
|
key: 'payment.after_sale',
|
||||||
|
title: '售后策略',
|
||||||
|
icon: 'fa fas fa-undo-alt',
|
||||||
|
description: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、申请说明、所需材料、图片上传)配置。',
|
||||||
|
defaults: {
|
||||||
|
after_sale_s: true,
|
||||||
|
after_sale_time_limit: 7,
|
||||||
|
after_sale_expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
|
||||||
|
after_sale_reasons: [
|
||||||
|
{ t: '质量问题' },
|
||||||
|
{ t: '假冒品牌' },
|
||||||
|
{ t: '其他原因' },
|
||||||
|
],
|
||||||
|
after_sale_desc: '',
|
||||||
|
after_sale_materials: [
|
||||||
|
{ t: '联系邮箱' },
|
||||||
|
{ t: '联系电话' },
|
||||||
|
],
|
||||||
|
after_sale_reason_placeholder: '必填,请您详细填写申请原因',
|
||||||
|
after_sale_img_s: true,
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
title: '售后功能',
|
||||||
|
icon: 'fa fas fa-undo-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'after_sale_s', label: '售后功能', type: 'switch', default: true, help: '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮' },
|
||||||
|
{ key: 'after_sale_time_limit', label: '申请售后时效(天)', type: 'number', default: 7, min: 0, max: 3650, help: '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制' },
|
||||||
|
{ key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html', default: '<div>抱歉,您的订单已超过售后申请时效</div>', rows: 3, help: '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'form',
|
||||||
|
title: '申请表单',
|
||||||
|
icon: 'fa fas fa-file-alt',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'after_sale_reasons',
|
||||||
|
label: '预置原因',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加原因',
|
||||||
|
help: '用户申请售后时可选择的原因',
|
||||||
|
default: [
|
||||||
|
{ t: '质量问题' },
|
||||||
|
{ t: '假冒品牌' },
|
||||||
|
{ t: '其他原因' },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: 't', label: '原因', type: 'input', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 5, help: '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)' },
|
||||||
|
{
|
||||||
|
key: 'after_sale_materials',
|
||||||
|
label: '申请所需材料',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加材料',
|
||||||
|
help: '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核',
|
||||||
|
default: [
|
||||||
|
{ t: '联系邮箱' },
|
||||||
|
{ t: '联系电话' },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: 't', label: '资料项', type: 'input', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因', help: '前台申请售后弹窗中「申请说明」输入框的提示文字' },
|
||||||
|
{ key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true, help: '开启后申请售后弹窗中出现图片上传入口(最多上传3张)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
export const cardPassSchema = {
|
|
||||||
key: 'payment.card-pass',
|
|
||||||
title: '充值卡',
|
|
||||||
icon: 'fa fas fa-ticket-alt',
|
|
||||||
description: '配置充值卡类型、卡号规则、批量生成和兑换逻辑。',
|
|
||||||
defaults: {
|
|
||||||
card_pass_enabled: false,
|
|
||||||
card_pass_desc: '',
|
|
||||||
card_pass_type: 'balance',
|
|
||||||
card_pass_prefix: 'AK',
|
|
||||||
card_pass_length: 16,
|
|
||||||
card_pass_batch_size: 50,
|
|
||||||
card_pass_max_redeem: 1,
|
|
||||||
card_pass_expiry: 0,
|
|
||||||
card_pass_amount: '10.00',
|
|
||||||
card_pass_vip_days: 30,
|
|
||||||
card_pass_credits: 0,
|
|
||||||
card_pass_redeem_page: '',
|
|
||||||
card_pass_success_msg: '兑换成功!',
|
|
||||||
card_pass_fail_msg: '卡号无效或已过期',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '充值卡设置',
|
|
||||||
icon: 'fa fas fa-id-card',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_enabled', label: '启用充值卡', type: 'switch', default: false },
|
|
||||||
{ key: 'card_pass_desc', label: '充值卡说明', type: 'textarea', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_type', label: '充值卡类型', type: 'select', default: 'balance', options: [{ label: '余额卡', value: 'balance' }, { label: 'VIP卡', value: 'vip' }, { label: '积分卡', value: 'credits' }], dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_prefix', label: '卡号前缀', type: 'input', default: 'AK', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_length', label: '卡号长度', type: 'number', default: 16, min: 8, max: 32, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_batch_size', label: '批量生成数量', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'redeem',
|
|
||||||
title: '兑换设置',
|
|
||||||
icon: 'fa fas fa-exchange-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_max_redeem', label: '单卡最大兑换次数', type: 'number', default: 1, min: 1, max: 100, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_amount', label: '面额(元)', type: 'input', default: '10.00', min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_vip_days', label: 'VIP天数', type: 'number', default: 30, min: 1, max: 3650, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_credits', label: '赠送积分', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '展示设置',
|
|
||||||
icon: 'fa fas fa-desktop',
|
|
||||||
fields: [
|
|
||||||
{ key: 'card_pass_redeem_page', label: '兑换页面', type: 'input', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_success_msg', label: '兑换成功提示', type: 'input', default: '兑换成功!', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
{ key: 'card_pass_fail_msg', label: '兑换失败提示', type: 'input', default: '卡号无效或已过期', dependsOn: { key: 'card_pass_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,83 @@
|
|||||||
|
/** PayPal / Stripe 结算货币选项(参考主题同款 26 种)。 */
|
||||||
|
const currencyOptions = [
|
||||||
|
{ label: '澳元 (AUD)', value: 'AUD' },
|
||||||
|
{ label: '巴西雷亚尔 (BRL)', value: 'BRL' },
|
||||||
|
{ label: '加拿大元 (CAD)', value: 'CAD' },
|
||||||
|
{ label: '人民币 (CNY)', value: 'CNY' },
|
||||||
|
{ label: '克朗 (CZK)', value: 'CZK' },
|
||||||
|
{ label: '丹麦克朗 (DKK)', value: 'DKK' },
|
||||||
|
{ label: '欧元 (EUR)', value: 'EUR' },
|
||||||
|
{ label: '港币 (HKD)', value: 'HKD' },
|
||||||
|
{ label: '匈牙利福林 (HUF)', value: 'HUF' },
|
||||||
|
{ label: '印度卢比 (INR)', value: 'INR' },
|
||||||
|
{ label: '以色列新谢克尔 (ILS)', value: 'ILS' },
|
||||||
|
{ label: '日元 (JPY)', value: 'JPY' },
|
||||||
|
{ label: '马来西亚林吉特 (MYR)', value: 'MYR' },
|
||||||
|
{ label: '墨西哥比索 (MXN)', value: 'MXN' },
|
||||||
|
{ label: '新台币 (TWD)', value: 'TWD' },
|
||||||
|
{ label: '新西兰元 (NZD)', value: 'NZD' },
|
||||||
|
{ label: '挪威克朗 (NOK)', value: 'NOK' },
|
||||||
|
{ label: '菲律宾比索 (PHP)', value: 'PHP' },
|
||||||
|
{ label: '波兰兹罗提 (PLN)', value: 'PLN' },
|
||||||
|
{ label: '英镑 (GBP)', value: 'GBP' },
|
||||||
|
{ label: '俄罗斯卢布 (RUB)', value: 'RUB' },
|
||||||
|
{ label: '新加坡元 (SGD)', value: 'SGD' },
|
||||||
|
{ label: '瑞典克朗 (SEK)', value: 'SEK' },
|
||||||
|
{ label: '瑞士法郎 (CHF)', value: 'CHF' },
|
||||||
|
{ label: '泰铢 (THB)', value: 'THB' },
|
||||||
|
{ label: '美元 (USD)', value: 'USD' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收款接口 — 参考主题双下拉式重构:微信/支付宝接口选择(官方/易支付/关闭)、
|
||||||
|
* PayPal/Stripe 独立开关、货币与结算、订单与编号、
|
||||||
|
* 收款接口显示顺序,以及 5 个凭据配置块(支付宝/微信/PayPal/Stripe/易支付)。
|
||||||
|
*/
|
||||||
export const gatewaySchema = {
|
export const gatewaySchema = {
|
||||||
key: 'payment.gateway',
|
key: 'payment.gateway',
|
||||||
title: '收款接口',
|
title: '收款接口',
|
||||||
icon: 'fa fas fa-credit-card',
|
icon: 'fa fas fa-credit-card',
|
||||||
description: '管理已支持的收款通道:余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换。开启渠道后展开填写凭据。',
|
description: '配置收款通道:微信与支付宝可在官方/易支付之间选择,PayPal 与 Stripe 独立开关。订单、资金流水与提现统一使用同一套自动流水号,无需配置前缀。',
|
||||||
defaults: {
|
defaults: {
|
||||||
payment_wechat_enabled: false,
|
pay_wechat_sdk_options: 'null',
|
||||||
payment_alipay_enabled: false,
|
pay_alipay_sdk_options: 'null',
|
||||||
payment_paypal_enabled: false,
|
pay_paypal_sdk_s: false,
|
||||||
payment_stripe_enabled: false,
|
pay_stripe_sdk_s: false,
|
||||||
payment_epay_enabled: false,
|
pay_currency_mark: '¥',
|
||||||
payment_balance_enabled: true,
|
pay_currency_unit: '元',
|
||||||
payment_card_pass_enabled: false,
|
pay_wechat_settle: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
|
||||||
wechat_merchantid: '',
|
pay_alipay_settle: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
|
||||||
wechat_appid: '',
|
pay_sdk_custom_title_s: false,
|
||||||
wechat_payment_type: 'native',
|
pay_sdk_custom_title: '',
|
||||||
wechat_api_v3_key: '',
|
payment_order_expiry: 30,
|
||||||
wechat_private_key_path: '',
|
pay_wait_float_s: true,
|
||||||
wechat_cert_path: '',
|
pay_sdk_order: {},
|
||||||
alipay_appid: '',
|
alipay_appid: '',
|
||||||
alipay_privatekey: '',
|
alipay_privatekey: '',
|
||||||
alipay_payment_type: 'page',
|
alipay_payment_type: 'page',
|
||||||
alipay_app_public_cert_path: '',
|
alipay_app_public_cert_path: '',
|
||||||
alipay_public_cert_path: '',
|
alipay_public_cert_path: '',
|
||||||
alipay_root_cert_path: '',
|
alipay_root_cert_path: '',
|
||||||
|
wechat_merchantid: '',
|
||||||
|
wechat_appid: '',
|
||||||
|
wechat_payment_type: 'native',
|
||||||
|
wechat_api_v3_key: '',
|
||||||
|
wechat_private_key_path: '',
|
||||||
|
wechat_cert_path: '',
|
||||||
paypal_client_id: '',
|
paypal_client_id: '',
|
||||||
paypal_client_secret: '',
|
paypal_client_secret: '',
|
||||||
paypal_currency: 'USD',
|
paypal_currency: 'USD',
|
||||||
paypal_sandbox: true,
|
paypal_rates: '0.14',
|
||||||
|
paypal_sandbox: false,
|
||||||
stripe_secret_key: '',
|
stripe_secret_key: '',
|
||||||
stripe_publishable_key: '',
|
stripe_publishable_key: '',
|
||||||
stripe_webhook_secret: '',
|
stripe_webhook_secret: '',
|
||||||
stripe_currency: 'usd',
|
stripe_currency: 'USD',
|
||||||
stripe_test_mode: true,
|
stripe_rates: '0.14',
|
||||||
|
stripe_test_mode: false,
|
||||||
epay_apiurl: '',
|
epay_apiurl: '',
|
||||||
epay_pid: '',
|
epay_pid: '',
|
||||||
epay_key: '',
|
epay_key: '',
|
||||||
epay_channel_alipay: true,
|
|
||||||
epay_channel_wxpay: true,
|
|
||||||
epay_device: 'auto',
|
epay_device: 'auto',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
@@ -46,121 +87,259 @@ export const gatewaySchema = {
|
|||||||
icon: 'fa fas fa-info-circle',
|
icon: 'fa fas fa-info-circle',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'payment_gateway_notice',
|
key: 'gateway_notice',
|
||||||
label: '通道说明',
|
label: '',
|
||||||
type: 'notice',
|
type: 'content',
|
||||||
alertType: 'info',
|
ui_only: true,
|
||||||
alertContent: '以渠道开关 + 凭据是否齐全为准。异步回调由各网关验签(支付宝/微信/Stripe/易支付等),无需额外口令。卡密为兑换入口,不出现在收银台支付方式列表。',
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gateways',
|
key: 'channels',
|
||||||
title: '支付渠道开关',
|
title: '收款接口选择',
|
||||||
icon: 'fa fas fa-toggle-on',
|
icon: 'fa fas fa-toggle-on',
|
||||||
description: '先开启渠道,下方对应配置卡片会展开;填写凭据后网关状态变为可用。余额与卡密无第三方凭据,仅开关即可。',
|
description: '微信与支付宝各选一种接入方式;选「易支付」后需在下方易支付配置块填写接口信息。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'payment_balance_enabled', label: '余额支付', type: 'switch', default: true, help: '需同时在「余额充值」启用余额功能。关闭后用户中心仍可有钱包,但结账不可用余额支付。' },
|
{
|
||||||
{ key: 'payment_alipay_enabled', label: '支付宝', type: 'switch', default: false },
|
key: 'pay_wechat_sdk_options',
|
||||||
{ key: 'payment_wechat_enabled', label: '微信支付', type: 'switch', default: false },
|
label: '微信收款接口',
|
||||||
{ key: 'payment_paypal_enabled', label: 'PayPal', type: 'switch', default: false },
|
type: 'select',
|
||||||
{ key: 'payment_stripe_enabled', label: 'Stripe', type: 'switch', default: false },
|
default: 'null',
|
||||||
{ key: 'payment_epay_enabled', label: '易支付', type: 'switch', default: false, help: '兼容彩虹易支付协议的聚合收银台,可同时开启支付宝/微信子通道。' },
|
options: [
|
||||||
{ key: 'payment_card_pass_enabled', label: '卡密充值', type: 'switch', default: false, help: '仅控制卡密兑换入口是否开放;卡密规则与批量发卡在「充值卡」页。不进入结账支付方式列表。' },
|
{ label: '微信官方', value: 'official_wechat' },
|
||||||
|
{ label: '易支付-微信', value: 'epay' },
|
||||||
|
{ label: '关闭微信收款', value: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pay_alipay_sdk_options',
|
||||||
|
label: '支付宝收款接口',
|
||||||
|
type: 'select',
|
||||||
|
default: 'null',
|
||||||
|
options: [
|
||||||
|
{ label: '支付宝企业支付/当面付', value: 'official_alipay' },
|
||||||
|
{ label: '易支付-支付宝', value: 'epay' },
|
||||||
|
{ label: '关闭支付宝收款', value: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_paypal_sdk_s', label: 'PayPal支付(贝宝)', type: 'switch', default: false, help: '启用后请在下方配置参数' },
|
||||||
|
{ key: 'pay_stripe_sdk_s', label: 'Stripe支付', type: 'switch', default: false, help: '启用后请在下方配置参数' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'currency',
|
||||||
|
title: '货币与结算',
|
||||||
|
icon: 'fa fas fa-coins',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_currency_mark', label: '本地货币符号', type: 'input', default: '¥', help: '显示在金额前面(例如 $ 或 ¥)' },
|
||||||
|
{ key: 'pay_currency_unit', label: '本地货币单位名称', type: 'input', default: '元', help: '金额后缀,例如:人民币填「元」、美元填「dollars」' },
|
||||||
|
{
|
||||||
|
key: 'pay_wechat_settle',
|
||||||
|
label: '微信结算配置',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'settle_rate', label: '结算汇率', type: 'input', default: '1' },
|
||||||
|
{ key: 'settle_mark', label: '结算货币符号', type: 'input', default: '¥' },
|
||||||
|
{ key: 'settle_unit', label: '结算货币单位', type: 'input', default: '元' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pay_alipay_settle',
|
||||||
|
label: '支付宝结算配置',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'settle_rate', label: '结算汇率', type: 'input', default: '1' },
|
||||||
|
{ key: 'settle_mark', label: '结算货币符号', type: 'input', default: '¥' },
|
||||||
|
{ key: 'settle_unit', label: '结算货币单位', type: 'input', default: '元', help: '如果接入的收款方式与本地的货币类型不同,则在此配置结算汇率及单位' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_sdk_custom_title_s', label: '自定义收款标题', type: 'switch', default: false, help: '默认为付款类型加网站名,如需自定义请开启' },
|
||||||
|
{ key: 'pay_sdk_custom_title', label: '自定义收款标题', type: 'input', default: '', dependsOn: { key: 'pay_sdk_custom_title_s', value: true }, help: '可使用变量:订单类型 %order_type%,例如:%order_type%-AKSRT;注意控制总长度,太长会导致部分接口报错' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'order',
|
||||||
|
title: '订单与编号',
|
||||||
|
icon: 'fa fas fa-hashtag',
|
||||||
|
description: '订单支付时效与浮窗提示;订单、资金流水与提现的流水号由系统统一自动分配,无需配置。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'payment_order_expiry', label: '订单支付时效(分钟)', type: 'number', default: 30, min: 5, max: 1440, help: '用户下单后,最大支付时间,超过后订单将自动关闭(不能低于5分钟)' },
|
||||||
|
{ key: 'pay_wait_float_s', label: '浮窗显示待支付订单', type: 'switch', default: true, help: '将用户待支付订单一直显示在右上角,提醒用户付款' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sdk-order',
|
||||||
|
title: '收款接口显示顺序',
|
||||||
|
icon: 'fa fas fa-sort-amount-down',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_sdk_order',
|
||||||
|
label: '显示顺序',
|
||||||
|
type: 'sortable',
|
||||||
|
default: {},
|
||||||
|
items: [
|
||||||
|
{ key: 'wechat', label: '微信' },
|
||||||
|
{ key: 'alipay', label: '支付宝' },
|
||||||
|
{ key: 'paypal', label: 'PayPal(贝宝)' },
|
||||||
|
{ key: 'stripe', label: 'Stripe' },
|
||||||
|
{ key: 'balance', label: '余额' },
|
||||||
|
{ key: 'card_pass', label: '卡密' },
|
||||||
|
],
|
||||||
|
help: '收银台支付方式显示顺序;仅显示已开启的接口',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alipay',
|
||||||
|
title: '支付宝官方',
|
||||||
|
icon: 'fa fas fa-credit-card',
|
||||||
|
description: '支付宝官方接口(当面付 / 网站应用)。',
|
||||||
|
collapsible: true,
|
||||||
|
expandWhen: 'pay_alipay_sdk_options',
|
||||||
|
dependsOn: { key: 'pay_alipay_sdk_options', value: 'official_alipay' },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'alipay_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>',
|
||||||
|
},
|
||||||
|
{ key: 'alipay_appid', label: '当面付:APPID', type: 'input', default: '' },
|
||||||
|
{ key: 'alipay_privatekey', label: '当面付:应用私钥', type: 'textarea', sanitize_type: 'code', default: '', rows: 4 },
|
||||||
|
{
|
||||||
|
key: 'alipay_payment_type',
|
||||||
|
label: '支付方式',
|
||||||
|
type: 'select',
|
||||||
|
default: 'page',
|
||||||
|
options: [
|
||||||
|
{ label: '电脑网站支付(web)', value: 'page' },
|
||||||
|
{ label: '手机网站支付(wap)', value: 'wap' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' },
|
||||||
|
{ key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' },
|
||||||
|
{ key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'wechat',
|
key: 'wechat',
|
||||||
title: '微信支付配置',
|
title: '微信官方',
|
||||||
icon: 'fa fab fa-weixin',
|
icon: 'fa fab fa-weixin',
|
||||||
description: '开启「微信支付」后填写商户号与密钥。',
|
description: '微信企业支付官方接口。',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
expandWhen: 'payment_wechat_enabled',
|
expandWhen: 'pay_wechat_sdk_options',
|
||||||
dependsOn: { key: 'payment_wechat_enabled', value: true },
|
dependsOn: { key: 'pay_wechat_sdk_options', value: 'official_wechat' },
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'wechat_merchantid', label: '商户号', type: 'input', default: '' },
|
{
|
||||||
{ key: 'wechat_appid', label: 'AppID', type: 'input', default: '' },
|
key: 'wechat_notice',
|
||||||
{ key: 'wechat_payment_type', label: '支付方式', type: 'select', default: 'native', options: [
|
label: '',
|
||||||
{ label: '扫码支付(Native)', value: 'native' },
|
type: 'content',
|
||||||
{ label: 'H5支付', value: 'h5' },
|
ui_only: true,
|
||||||
] },
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>',
|
||||||
{ key: 'wechat_api_v3_key', label: 'APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
|
},
|
||||||
|
{ key: 'wechat_merchantid', label: '商户号 PartnerID', type: 'input', default: '' },
|
||||||
|
{ key: 'wechat_appid', label: '授权绑定的AppID', type: 'input', default: '' },
|
||||||
|
{
|
||||||
|
key: 'wechat_payment_type',
|
||||||
|
label: '支付方式',
|
||||||
|
type: 'select',
|
||||||
|
default: 'native',
|
||||||
|
options: [
|
||||||
|
{ label: '扫码支付(Native)', value: 'native' },
|
||||||
|
{ label: 'H5支付', value: 'h5' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'wechat_api_v3_key', label: '支付APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
|
||||||
{ key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' },
|
{ key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' },
|
||||||
{ key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' },
|
{ key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'alipay',
|
|
||||||
title: '支付宝配置',
|
|
||||||
icon: 'fa fas fa-credit-card',
|
|
||||||
description: '开启「支付宝」后填写应用与证书。',
|
|
||||||
collapsible: true,
|
|
||||||
expandWhen: 'payment_alipay_enabled',
|
|
||||||
dependsOn: { key: 'payment_alipay_enabled', value: true },
|
|
||||||
fields: [
|
|
||||||
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
|
|
||||||
{ key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' },
|
|
||||||
{ key: 'alipay_payment_type', label: '支付方式', type: 'select', default: 'page', options: [
|
|
||||||
{ label: '电脑网站支付(web)', value: 'page' },
|
|
||||||
{ label: '手机网站支付(wap)', value: 'wap' },
|
|
||||||
] },
|
|
||||||
{ key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' },
|
|
||||||
{ key: 'alipay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步通知 / 同步返回已固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return},无需在此填写。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'paypal',
|
key: 'paypal',
|
||||||
title: 'PayPal 配置',
|
title: 'PayPal(贝宝)',
|
||||||
icon: 'fa fab fa-paypal',
|
icon: 'fa fab fa-paypal',
|
||||||
description: '开启「PayPal」后填写 Client 凭据。',
|
description: '常见的海外支付接口。',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
expandWhen: 'payment_paypal_enabled',
|
expandWhen: 'pay_paypal_sdk_s',
|
||||||
dependsOn: { key: 'payment_paypal_enabled', value: true },
|
dependsOn: { key: 'pay_paypal_sdk_s', value: true },
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'paypal_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>',
|
||||||
|
},
|
||||||
{ key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' },
|
{ key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' },
|
||||||
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' },
|
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' },
|
||||||
{ key: 'paypal_currency', label: '货币', type: 'input', default: 'USD' },
|
{ key: 'paypal_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
|
||||||
{ key: 'paypal_sandbox', label: '沙箱模式', type: 'switch', default: true },
|
{ key: 'paypal_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
|
||||||
|
{ key: 'paypal_sandbox', label: '沙盒调试模式', type: 'switch', default: false, help: '沙箱模式(Sandbox)是PayPal支付的测试环境。开启后请使用沙箱环境生成的收款账户和付款账户进行测试。' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'stripe',
|
key: 'stripe',
|
||||||
title: 'Stripe 配置',
|
title: 'Stripe',
|
||||||
icon: 'fa fab fa-cc-stripe',
|
icon: 'fa fab fa-cc-stripe',
|
||||||
description: '开启「Stripe」后填写 API Key 与 Webhook。',
|
description: '常见的海外信用卡收款接口。',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
expandWhen: 'payment_stripe_enabled',
|
expandWhen: 'pay_stripe_sdk_s',
|
||||||
dependsOn: { key: 'payment_stripe_enabled', value: true },
|
dependsOn: { key: 'pay_stripe_sdk_s', value: true },
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'stripe_secret_key', label: 'Secret Key', type: 'input', default: '' },
|
{
|
||||||
|
key: 'stripe_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>',
|
||||||
|
},
|
||||||
|
{ key: 'stripe_secret_key', label: '私钥Secret Key', type: 'input', default: '', help: 'Stripe Dashboard → Developers → API keys → Secret key' },
|
||||||
{ key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' },
|
{ key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' },
|
||||||
{ key: 'stripe_webhook_secret', label: 'Webhook Secret', type: 'input', default: '', help: 'Stripe Dashboard 配置 Webhook 后获得,用于验签。' },
|
{ key: 'stripe_webhook_secret', label: '签名密钥Webhook Secret', type: 'input', default: '', help: 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)' },
|
||||||
{ key: 'stripe_currency', label: '货币', type: 'input', default: 'usd' },
|
{ key: 'stripe_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
|
||||||
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: true },
|
{ key: 'stripe_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
|
||||||
|
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: false, help: '如果使用的测试开放模式,则请使用 sk_test_ 测试密钥与 Stripe 测试卡进行配置' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'epay',
|
key: 'epay',
|
||||||
title: '易支付配置',
|
title: '易支付',
|
||||||
icon: 'fa fas fa-university',
|
icon: 'fa fas fa-university',
|
||||||
description: '开启「易支付」后填写接口地址与商户信息,并选择支付宝/微信子通道。',
|
description: '易支付(彩虹协议)聚合收银台;通道由上方微信/支付宝接口选择控制。',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
expandWhen: 'payment_epay_enabled',
|
expandWhen: 'pay_wechat_sdk_options',
|
||||||
dependsOn: { key: 'payment_epay_enabled', value: true },
|
dependsOn: {
|
||||||
|
any: [
|
||||||
|
{ key: 'pay_wechat_sdk_options', value: 'epay' },
|
||||||
|
{ key: 'pay_alipay_sdk_options', value: 'epay' },
|
||||||
|
],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'epay_apiurl', label: '接口地址', type: 'input', default: '', help: '易支付站点根地址,如 https://pay.example.com/(末尾斜杠可选)。' },
|
{
|
||||||
{ key: 'epay_pid', label: '商户ID', type: 'input', default: '' },
|
key: 'epay_notice',
|
||||||
{ key: 'epay_key', label: '商户密钥', type: 'input', default: '', help: 'MD5 通信密钥。' },
|
label: '',
|
||||||
{ key: 'epay_channel_alipay', label: '启用支付宝通道', type: 'switch', default: true },
|
type: 'content',
|
||||||
{ key: 'epay_channel_wxpay', label: '启用微信通道', type: 'switch', default: true },
|
ui_only: true,
|
||||||
{ key: 'epay_device', label: '下单终端', type: 'select', default: 'auto', options: [
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>',
|
||||||
{ label: '自动(按 UA)', value: 'auto' },
|
},
|
||||||
{ label: '电脑端 submit', value: 'pc' },
|
{ key: 'epay_apiurl', label: 'API接口网址', type: 'input', default: '', help: '请填写接口地址,例如:https://pay.xxxxxxx.cn/' },
|
||||||
{ label: '移动端 mapi', value: 'mobile' },
|
{ key: 'epay_pid', label: '商户号', type: 'input', default: '' },
|
||||||
] },
|
{ key: 'epay_key', label: '商户秘钥', type: 'input', default: '', help: 'MD5 通信密钥。' },
|
||||||
{ key: 'epay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步/同步回调固定为 /wp-json/aksrt/v1/payment/epay_alipay/{notify|return} 与 /payment/epay_wxpay/{notify|return},请在易支付后台放行对应 URL。' },
|
{
|
||||||
|
key: 'epay_device',
|
||||||
|
label: '下单终端',
|
||||||
|
type: 'select',
|
||||||
|
default: 'auto',
|
||||||
|
options: [
|
||||||
|
{ label: '自动(按 UA)', value: 'auto' },
|
||||||
|
{ label: '电脑端 submit', value: 'pc' },
|
||||||
|
{ label: '移动端 mapi', value: 'mobile' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,23 +2,22 @@
|
|||||||
* Payment & Paid domain schemas.
|
* Payment & Paid domain schemas.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { knowledgeSchema } from './knowledge.js'
|
||||||
import { rechargeSchema } from './recharge.js'
|
import { rechargeSchema } from './recharge.js'
|
||||||
import { vipSchema } from './vip.js'
|
import { vipSchema } from './vip.js'
|
||||||
import { revenueSchema } from './revenue.js'
|
import { revenueSchema } from './revenue.js'
|
||||||
import { affiliateSchema } from './affiliate.js'
|
import { affiliateSchema } from './affiliate.js'
|
||||||
import { gatewaySchema } from './gateway.js'
|
import { gatewaySchema } from './gateway.js'
|
||||||
import { orderSchema } from './order.js'
|
|
||||||
import { cardPassSchema } from './cardPass.js'
|
|
||||||
import { withdrawalSchema } from './withdrawal.js'
|
import { withdrawalSchema } from './withdrawal.js'
|
||||||
|
import { afterSaleSchema } from './afterSale.js'
|
||||||
|
|
||||||
export const paymentSchemas = {
|
export const paymentSchemas = {
|
||||||
|
knowledge: knowledgeSchema,
|
||||||
recharge: rechargeSchema,
|
recharge: rechargeSchema,
|
||||||
vip: vipSchema,
|
vip: vipSchema,
|
||||||
revenue: revenueSchema,
|
revenue: revenueSchema,
|
||||||
affiliate: affiliateSchema,
|
affiliate: affiliateSchema,
|
||||||
gateway: gatewaySchema,
|
gateway: gatewaySchema,
|
||||||
order: orderSchema,
|
|
||||||
'card-pass': cardPassSchema,
|
|
||||||
cardPass: cardPassSchema,
|
|
||||||
withdrawal: withdrawalSchema,
|
withdrawal: withdrawalSchema,
|
||||||
|
after_sale: afterSaleSchema,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* 知识付费 — 1:1 复刻参考主题对应页面(免登陆购买 / 模块位置 / 优惠码 /
|
||||||
|
* 客户服务 / 付费下载 / 商品预置参数),并保留 AKSRT 订单展示与状态文案。
|
||||||
|
*/
|
||||||
|
export const knowledgeSchema = {
|
||||||
|
key: 'payment.knowledge',
|
||||||
|
title: '知识付费',
|
||||||
|
icon: 'fa fas fa-shopping-bag',
|
||||||
|
description: '付费内容总开关、免登陆购买、模块显示、优惠码与商品预置参数配置。',
|
||||||
|
defaults: {
|
||||||
|
pay_no_logged_in: true,
|
||||||
|
pay_cookie_day: 15,
|
||||||
|
pay_no_logged_remind: '您当前未登录!建议登陆后购买,可保存购买订单',
|
||||||
|
pay_box_position: 'top',
|
||||||
|
pay_free_logged_show: true,
|
||||||
|
pay_show_user: true,
|
||||||
|
pay_show_paycount: true,
|
||||||
|
coupon_post_s: false,
|
||||||
|
coupon_desc: '',
|
||||||
|
pay_mark: '付费',
|
||||||
|
payment_show_ip: false,
|
||||||
|
payment_show_device: false,
|
||||||
|
payment_show_order_no: true,
|
||||||
|
payment_show_create_time: true,
|
||||||
|
payment_show_pay_time: true,
|
||||||
|
payment_order_status_pending: '待支付',
|
||||||
|
payment_order_status_paid: '已支付',
|
||||||
|
payment_order_status_refunded: '已退款',
|
||||||
|
payment_order_status_closed: '已关闭',
|
||||||
|
pay_service: [],
|
||||||
|
down_alone_page: false,
|
||||||
|
down_remote_to_local_s: true,
|
||||||
|
down_remote_to_local_size: 2048,
|
||||||
|
down_remote_to_local_ext: 'zip,rar,7z,text,png,jpg,jpeg,gif,mp3,mp4,avi,wmv,flv,swf,doc,docx,xls,xlsx,ppt,pptx,pdf',
|
||||||
|
down_verify_nonce: true,
|
||||||
|
pay_modo_default: '0',
|
||||||
|
points_price_default: 0,
|
||||||
|
vip_1_points_default: 0,
|
||||||
|
vip_2_points_default: 0,
|
||||||
|
pay_price_default: '0.01',
|
||||||
|
pay_original_price_default: '',
|
||||||
|
pay_promotion_tag_default: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠',
|
||||||
|
vip_1_price_default: '0',
|
||||||
|
vip_2_price_default: '0',
|
||||||
|
pay_rebate_discount: '0',
|
||||||
|
pay_cuont_default: { min: 0, max: 0 },
|
||||||
|
pay_details_default: '',
|
||||||
|
pay_extra_hide_default: '',
|
||||||
|
pay_attributes_default: [],
|
||||||
|
pay_gallery_show_default: 1,
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'notice',
|
||||||
|
title: '说明',
|
||||||
|
icon: 'fa fas fa-info-circle',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'knowledge_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<p><b>知识付费</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>知识付费功能主要分为文章付费、付费会员、余额系统、推广返佣、创作分成等功能组成,您可以根据您的需要开启对应功能</li><li>使用支付功能请先在「收款接口」页配置好收款通道</li></ul>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'guest-buy',
|
||||||
|
title: '免登陆购买',
|
||||||
|
icon: 'fa fas fa-user-clock',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_no_logged_in', label: '免登陆购买', type: 'switch', default: true, help: '开启后如果用户未登录则使用浏览器缓存验证是否购买' },
|
||||||
|
{ key: 'pay_cookie_day', label: 'Cookie时间(天)', type: 'number', default: 15, min: 1, max: 31, dependsOn: { key: 'pay_no_logged_in', value: true }, help: '免登陆购买的浏览器缓存有效时间' },
|
||||||
|
{ key: 'pay_no_logged_remind', label: '未登录提醒', type: 'textarea', default: '您当前未登录!建议登陆后购买,可保存购买订单', rows: 2, dependsOn: { key: 'pay_no_logged_in', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'box-position',
|
||||||
|
title: '模块显示位置',
|
||||||
|
icon: 'fa fas fa-arrows-alt',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_box_position',
|
||||||
|
label: '购买模块位置',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'top',
|
||||||
|
options: [
|
||||||
|
{ label: '文章模块上方', value: 'box_top' },
|
||||||
|
{ label: '文章内容顶部', value: 'top' },
|
||||||
|
{ label: '文章内容底部', value: 'bottom' },
|
||||||
|
{ label: '文章模块下方', value: 'box_bottom' },
|
||||||
|
],
|
||||||
|
help: '在文章页面中购买模块的显示位置',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display-basic',
|
||||||
|
title: '基础显示',
|
||||||
|
icon: 'fa fas fa-eye',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_free_logged_show', label: '免费资源登录可见', type: 'switch', default: true, help: '免费资源必须登录后才能查看' },
|
||||||
|
{ key: 'pay_show_user', label: '订单数据', type: 'switch', default: true, help: '在用户中心显示订单数据' },
|
||||||
|
{ key: 'pay_show_paycount', label: '销量显示', type: 'switch', default: true, help: '商品详情显示销售数量' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coupon',
|
||||||
|
title: '商品优惠码',
|
||||||
|
icon: 'fa fas fa-ticket-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'coupon_post_s', label: '为所有商品启用优惠券功能', type: 'switch', default: false, help: '开启后将为所有商品显示输入优惠码的功能;也可以单独给任意一个商品开启' },
|
||||||
|
{ key: 'coupon_desc', label: '优惠码默认说明', type: 'textarea', default: '', rows: 1, dependsOn: { key: 'coupon_post_s', value: true }, help: '用户填写优惠码时展示的提醒内容,支持HTML代码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display',
|
||||||
|
title: '展示设置',
|
||||||
|
icon: 'fa fas fa-list-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_mark', label: '付费标记', type: 'input', default: '付费' },
|
||||||
|
{ key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false },
|
||||||
|
{ key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false },
|
||||||
|
{ key: 'payment_show_order_no', label: '显示订单号', type: 'switch', default: true },
|
||||||
|
{ key: 'payment_show_create_time', label: '显示创建时间', type: 'switch', default: true },
|
||||||
|
{ key: 'payment_show_pay_time', label: '显示支付时间', type: 'switch', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态设置',
|
||||||
|
icon: 'fa fas fa-tags',
|
||||||
|
fields: [
|
||||||
|
{ key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' },
|
||||||
|
{ key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' },
|
||||||
|
{ key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' },
|
||||||
|
{ key: 'payment_order_status_closed', label: '已关闭状态文案', type: 'input', default: '已关闭' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'service',
|
||||||
|
title: '客户服务',
|
||||||
|
icon: 'fa fas fa-headphones',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_service',
|
||||||
|
label: '用户服务内容',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加服务项',
|
||||||
|
default: [],
|
||||||
|
fields: [
|
||||||
|
{ key: 'value', label: '服务内容', type: 'input', default: '' },
|
||||||
|
{ key: 'icon', label: '自定义图标', type: 'icon', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'feature',
|
||||||
|
title: '商品功能配置',
|
||||||
|
icon: 'fa fas fa-cubes',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_type_option',
|
||||||
|
label: '商品功能配置',
|
||||||
|
type: 'tabbed',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
title: '付费下载',
|
||||||
|
fields: [
|
||||||
|
{ key: 'down_alone_page', label: '独立下载页面', type: 'switch', default: false, help: '开启后,付费资源的下载链接会跳转独立页面显示' },
|
||||||
|
{ key: 'down_remote_to_local_s', label: '远程文件转本地文件下载', type: 'switch', default: true, help: '当下载地址为外链文件时,先将远程文件下载到本地服务器,再提供给用户下载,可完全隐藏远程下载地址' },
|
||||||
|
{ key: 'down_remote_to_local_size', label: '文件大小限制(Kb)', type: 'number', default: 2048, min: 0 },
|
||||||
|
{ key: 'down_remote_to_local_ext', label: '文件格式限制', type: 'input', default: 'zip,rar,7z,text,png,jpg,jpeg,gif,mp3,mp4,avi,wmv,flv,swf,doc,docx,xls,xlsx,ppt,pptx,pdf', help: '限制使用此功能的文件格式,多个用英文逗号隔开' },
|
||||||
|
{ key: 'down_verify_nonce', label: '下载前检测用户环境', type: 'switch', default: true, help: '防止机器批量扫描下载,有效防止跨站攻击' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'presets',
|
||||||
|
title: '商品预置参数配置',
|
||||||
|
icon: 'fa fas fa-sliders-h',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'presets_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '在下方配置的参数新建文章时候会自动填入,方便新建文章的设置。最终以文章配置为准',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pay_modo_default',
|
||||||
|
label: '支付类型',
|
||||||
|
type: 'radio',
|
||||||
|
default: '0',
|
||||||
|
options: [
|
||||||
|
{ label: '普通商品(金钱购买)', value: '0' },
|
||||||
|
{ label: '积分商品(积分购买,依赖于用户积分功能)', value: 'points' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'points_price_default', label: '积分售价(积分)', type: 'number', default: 0, min: 0 },
|
||||||
|
{ key: 'vip_1_points_default', label: '一级会员积分售价(积分)', type: 'number', default: 0, min: 0 },
|
||||||
|
{ key: 'vip_2_points_default', label: '二级会员积分售价(积分)', type: 'number', default: 0, min: 0 },
|
||||||
|
{ key: 'pay_price_default', label: '执行价', type: 'input', sanitize_type: 'money_decimal', default: '0.01', suffix: '元' },
|
||||||
|
{ key: 'pay_original_price_default', label: '原价', type: 'input', sanitize_type: 'money_decimal', default: '', suffix: '元', help: '显示在执行价格前面,并划掉' },
|
||||||
|
{ key: 'pay_promotion_tag_default', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', rows: 1 },
|
||||||
|
{ key: 'vip_1_price_default', label: '一级会员价格', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '填0则为一级会员免费' },
|
||||||
|
{ key: 'vip_2_price_default', label: '二级会员价格', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '填0则为二级会员免费' },
|
||||||
|
{ key: 'pay_rebate_discount', label: '推广折扣', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '通过推广链接购买,额外优惠的金额;需开启推广返佣功能,且不能超过实际购买价' },
|
||||||
|
{ key: 'pay_cuont_default', label: '销量浮动', type: 'between_number', default: { min: 0, max: 0 }, help: '为真实销量增加或减少的数量,系统会根据此处设置的区间获取随机值作为预置参数' },
|
||||||
|
{ key: 'pay_details_default', label: '更多详情', type: 'textarea', sanitize_type: 'html', default: '', rows: 3, help: '可插入任意的HTML代码' },
|
||||||
|
{ key: 'pay_extra_hide_default', label: '额外隐藏内容', type: 'textarea', sanitize_type: 'html', default: '', rows: 3, help: '可插入任意的HTML代码' },
|
||||||
|
{
|
||||||
|
key: 'pay_attributes_default',
|
||||||
|
label: '资源属性',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加属性',
|
||||||
|
default: [],
|
||||||
|
fields: [
|
||||||
|
{ key: 'key', label: '属性名称', type: 'input', default: '' },
|
||||||
|
{ key: 'value', label: '属性内容', type: 'input', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_gallery_show_default', label: '付费图片免费查看数量(张)', type: 'number', default: 1, min: 0, help: '设置付费图片可免费查看前几张图片的数量的默认值' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
export const orderSchema = {
|
|
||||||
key: 'payment.order',
|
|
||||||
title: '订单设置',
|
|
||||||
icon: 'fa fas fa-file-alt',
|
|
||||||
description: '配置订单前缀、有效期、展示字段和状态文案。',
|
|
||||||
defaults: {
|
|
||||||
payment_order_prefix: 'AKSRT',
|
|
||||||
payment_flow_prefix: 'AK',
|
|
||||||
payment_withdrawal_prefix: 'TX',
|
|
||||||
payment_order_expiry: 30,
|
|
||||||
pay_show_user: true,
|
|
||||||
pay_show_paycount: true,
|
|
||||||
pay_no_logged_in: true,
|
|
||||||
pay_cookie_day: 15,
|
|
||||||
pay_no_logged_remind: '您当前未登录!建议登陆后购买,可保存购买订单',
|
|
||||||
pay_wait_float_s: true,
|
|
||||||
payment_order_max_minutes: 30,
|
|
||||||
pay_mark: '付费',
|
|
||||||
pay_box_position: 'top',
|
|
||||||
payment_show_ip: false,
|
|
||||||
payment_show_device: false,
|
|
||||||
payment_show_order_no: true,
|
|
||||||
payment_show_create_time: true,
|
|
||||||
payment_show_pay_time: true,
|
|
||||||
payment_order_status_pending: '待支付',
|
|
||||||
payment_order_status_paid: '已支付',
|
|
||||||
payment_order_status_refunding: '退款中',
|
|
||||||
payment_order_status_refunded: '已退款',
|
|
||||||
payment_order_status_closed: '已关闭',
|
|
||||||
pay_service: '',
|
|
||||||
pay_service_icon: '',
|
|
||||||
pay_service_value: '',
|
|
||||||
pay_service_qq: '',
|
|
||||||
pay_service_wechat: '',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
title: '订单基础',
|
|
||||||
icon: 'fa fas fa-cog',
|
|
||||||
fields: [
|
|
||||||
{ key: 'payment_order_prefix', label: '订单前缀', type: 'input', default: 'AKSRT' },
|
|
||||||
{ key: 'payment_flow_prefix', label: '流水号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'AK', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
|
|
||||||
{ key: 'payment_withdrawal_prefix', label: '提现单号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'TX', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
|
|
||||||
{ key: 'payment_order_expiry', label: '订单有效期(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
|
|
||||||
{ key: 'pay_show_user', label: '显示用户信息', type: 'switch', default: true },
|
|
||||||
{ key: 'pay_show_paycount', label: '显示购买次数', type: 'switch', default: true },
|
|
||||||
{ key: 'pay_no_logged_in', label: '未登录提示', type: 'switch', default: true },
|
|
||||||
{ key: 'pay_cookie_day', label: 'Cookie有效期(天)', type: 'number', default: 15, min: 1, max: 31, help: '未登录购买 Cookie 有效时间,1-31 天' },
|
|
||||||
{ key: 'pay_no_logged_remind', label: '未登录提醒文案', type: 'textarea', default: '您当前未登录!建议登陆后购买,可保存购买订单', help: '未登录用户查看付费内容时的提醒文案' },
|
|
||||||
{ key: 'pay_wait_float_s', label: '订单等待浮动', type: 'switch', default: true },
|
|
||||||
{ key: 'payment_order_max_minutes', label: '订单最大支付时间(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '展示设置',
|
|
||||||
icon: 'fa fas fa-eye',
|
|
||||||
fields: [
|
|
||||||
{ key: 'pay_mark', label: '付费标记', type: 'input', default: '付费' },
|
|
||||||
{ key: 'pay_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章框顶部(封面后)', value: 'box_top' }, { label: '正文顶部', value: 'top' }, { label: '正文底部', value: 'bottom' }, { label: '文章框底部(相关前)', value: 'box_bottom' }] },
|
|
||||||
{ key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false },
|
|
||||||
{ key: 'payment_show_order_no', label: '显示订单号', type: 'switch', default: true },
|
|
||||||
{ key: 'payment_show_create_time', label: '显示创建时间', type: 'switch', default: true },
|
|
||||||
{ key: 'payment_show_pay_time', label: '显示支付时间', type: 'switch', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: '状态设置',
|
|
||||||
icon: 'fa fas fa-tags',
|
|
||||||
fields: [
|
|
||||||
{ key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' },
|
|
||||||
{ key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' },
|
|
||||||
{ key: 'payment_order_status_refunding', label: '退款中状态文案', type: 'input', default: '退款中' },
|
|
||||||
{ key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' },
|
|
||||||
{ key: 'payment_order_status_closed', label: '已关闭状态文案', type: 'input', default: '已关闭' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'service',
|
|
||||||
title: '服务设置',
|
|
||||||
icon: 'fa fas fa-headphones',
|
|
||||||
fields: [
|
|
||||||
{ key: 'pay_service', label: '客服设置', type: 'textarea', default: '' },
|
|
||||||
{ key: 'pay_service_icon', label: '客服图标', type: 'icon', default: '' },
|
|
||||||
{ key: 'pay_service_value', label: '客服内容', type: 'input', default: '' },
|
|
||||||
{ key: 'pay_service_qq', label: 'QQ客服', type: 'input', default: '' },
|
|
||||||
{ key: 'pay_service_wechat', label: '微信客服', type: 'media', default: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 余额充值 — 参考主题结构:用户余额(含余额提现)、转账、充值商品选项、
|
||||||
|
* 卡密兑换与余额兑换积分。卡密的生成/管理在后台「卡密管理」页完成,
|
||||||
|
* 此处不再保留「充值卡」配置项。
|
||||||
|
*/
|
||||||
export const rechargeSchema = {
|
export const rechargeSchema = {
|
||||||
key: 'payment.recharge',
|
key: 'payment.recharge',
|
||||||
title: '余额充值',
|
title: '余额充值',
|
||||||
icon: 'fa fas fa-bolt',
|
icon: 'fa fas fa-bolt',
|
||||||
description: '配置余额充值套餐、转账与积分兑换。',
|
description: '配置余额功能、充值商品选项、卡密兑换与余额兑换积分。',
|
||||||
defaults: {
|
defaults: {
|
||||||
pay_balance_s: true,
|
pay_balance_s: true,
|
||||||
|
pay_balance_withdraw_s: false,
|
||||||
pay_balance_desc: '',
|
pay_balance_desc: '',
|
||||||
pay_balance_product: '30|30|入门包\n98|108|推荐包',
|
|
||||||
pay_balance_product_name: '',
|
|
||||||
pay_balance_price: 0,
|
|
||||||
pay_balance_tag: '元',
|
|
||||||
pay_balance_tag_class: '',
|
|
||||||
pay_balance_product_custom_s: true,
|
|
||||||
pay_balance_product_custom_limit: 9999,
|
|
||||||
pay_balance_transfer_s: false,
|
pay_balance_transfer_s: false,
|
||||||
pay_balance_transfer_service_charge: 0,
|
pay_balance_transfer_service_charge: 0,
|
||||||
pay_balance_transfer_desc: '',
|
pay_balance_transfer_desc: '',
|
||||||
|
pay_balance_product: [
|
||||||
|
{ price: 50, pay_price: 0, tag: '', tag_class: '' },
|
||||||
|
{ price: 100, pay_price: 98, tag: '', tag_class: '' },
|
||||||
|
{ price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' },
|
||||||
|
],
|
||||||
|
pay_balance_product_custom_s: true,
|
||||||
|
pay_balance_product_custom_limit: 9999,
|
||||||
pay_balance_pass_charge_s: false,
|
pay_balance_pass_charge_s: false,
|
||||||
pay_balance_pass_charge_desc: '',
|
pay_balance_pass_charge_desc: '',
|
||||||
pay_balance_pass_charge_only_password: false,
|
pay_balance_pass_charge_only_password: false,
|
||||||
|
points_pass_exchange_s: false,
|
||||||
|
points_pass_exchange_desc: '',
|
||||||
|
points_pass_exchange_only_password: false,
|
||||||
|
pay_vip_pass_charge_s: false,
|
||||||
|
pay_vip_pass_charge_desc: '',
|
||||||
|
pay_vip_pass_charge_only_password: false,
|
||||||
pay_balance_points_exchange_s: false,
|
pay_balance_points_exchange_s: false,
|
||||||
pay_balance_points_exchange_rate: 100,
|
pay_balance_points_exchange_rate: 100,
|
||||||
pay_balance_points_exchange_min_points: 100,
|
pay_balance_points_exchange_min_points: 100,
|
||||||
@@ -26,34 +38,28 @@ export const rechargeSchema = {
|
|||||||
pay_balance_points_exchange_desc: '',
|
pay_balance_points_exchange_desc: '',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'notice',
|
||||||
|
title: '说明',
|
||||||
|
icon: 'fa fas fa-info-circle',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'recharge_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<p><b>用户余额功能:</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>开启后用户可充值到余额,余额可用于全站消费</li><li>在下方设置默认的分成比例,同时支持单独为每一个用户设置独立的分成比例</li><li>可以在用户管理中,为某一个用户手动赠送余额</li></ul>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
title: '充值开关',
|
title: '用户余额',
|
||||||
icon: 'fa fas fa-credit-card',
|
icon: 'fa fas fa-credit-card',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'pay_balance_s', label: '启用余额功能', type: 'switch', default: true, help: '开启后用户中心显示现金钱包,并默认允许余额支付(可在收款接口单独关闭)。充值需另配支付宝/微信等外部网关。' },
|
{ key: 'pay_balance_s', label: '启用余额充值/支付功能', type: 'switch', default: true, help: '开启后用户中心显示现金钱包,并可作为收银台支付方式' },
|
||||||
{ key: 'pay_balance_desc', label: '充值说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
|
{ key: 'pay_balance_withdraw_s', label: '余额提现', type: 'switch', default: false, dependsOn: { key: 'pay_balance_s', value: true }, help: '允许将余额提现' },
|
||||||
],
|
{ key: 'pay_balance_desc', label: '充值说明', type: 'textarea', default: '', rows: 3, dependsOn: { key: 'pay_balance_s', value: true }, help: '用户充值时显示的内容,建议为提醒事项、充值协议等,支持HTML代码' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'packages',
|
|
||||||
title: '充值套餐',
|
|
||||||
icon: 'fa fas fa-folder-open',
|
|
||||||
fields: [
|
|
||||||
{ key: 'pay_balance_product', label: '套餐列表', type: 'textarea', default: '30|30|入门包\n98|108|推荐包', help: '每行一个套餐:支付金额|到账金额|标题', dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'product',
|
|
||||||
title: '充值产品',
|
|
||||||
icon: 'fa fas fa-archive',
|
|
||||||
fields: [
|
|
||||||
{ key: 'pay_balance_product_name', label: '充值产品', type: 'input', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
{ key: 'pay_balance_price', label: '充值价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
{ key: 'pay_balance_tag', label: '价格标签', type: 'input', default: '元', dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
{ key: 'pay_balance_tag_class', label: '标签样式', type: 'input', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
{ key: 'pay_balance_product_custom_s', label: '自定义金额', type: 'switch', default: true, dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
{ key: 'pay_balance_product_custom_limit', label: '自定义金额限制', type: 'number', default: 9999, min: 1, max: 99999, dependsOn: { key: 'pay_balance_s', value: true } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,23 +67,56 @@ export const rechargeSchema = {
|
|||||||
title: '余额转账',
|
title: '余额转账',
|
||||||
icon: 'fa fas fa-exchange-alt',
|
icon: 'fa fas fa-exchange-alt',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'pay_balance_transfer_s', label: '启用余额转账', type: 'switch', default: false },
|
{ key: 'pay_balance_transfer_s', label: '启用余额转账', type: 'switch', default: false, help: '允许用户转账给其他用户' },
|
||||||
{ key: 'pay_balance_transfer_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'pay_balance_transfer_s', value: true } },
|
{ key: 'pay_balance_transfer_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'pay_balance_transfer_s', value: true } },
|
||||||
{ key: 'pay_balance_transfer_desc', label: '转账说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_transfer_s', value: true } },
|
{ key: 'pay_balance_transfer_desc', label: '转账说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_balance_transfer_s', value: true }, help: '用户转账时候显示的内容,建议为提醒事项、转账协议等,支持HTML代码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'products',
|
||||||
|
title: '充值商品选项',
|
||||||
|
icon: 'fa fas fa-archive',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_balance_product',
|
||||||
|
label: '充值金额选项',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加充值金额选项',
|
||||||
|
default: [
|
||||||
|
{ price: 50, pay_price: 0, tag: '', tag_class: '' },
|
||||||
|
{ price: 100, pay_price: 98, tag: '', tag_class: '' },
|
||||||
|
{ price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' },
|
||||||
|
],
|
||||||
|
help: '用户充值时候,可快速选择的充值金额,同时支持设置折扣',
|
||||||
|
fields: [
|
||||||
|
{ key: 'price', label: '充值金额', type: 'number', default: 100, min: 0, suffix: '元' },
|
||||||
|
{ key: 'pay_price', label: '销售金额', type: 'number', default: 0, min: 0, suffix: '元', help: '选填,设置不同的销售金额实现充值折扣的功能,设置为0则与充值金额一致' },
|
||||||
|
{ key: 'tag', label: '小标签', type: 'input', default: '', help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: 'tag_class', label: '标签颜色类', type: 'input', default: '', help: '主题标签颜色类,如 jb-cyan / jb-pink' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_balance_product_custom_s', label: '自定义充值金额', type: 'switch', default: true, help: '允许用户手动输入充值金额' },
|
||||||
|
{ key: 'pay_balance_product_custom_limit', label: '自定义充值金额上限', type: 'number', default: 9999, min: 1, max: 99999, dependsOn: { key: 'pay_balance_product_custom_s', value: true } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pass-charge',
|
key: 'pass-charge',
|
||||||
title: '充值卡免扣',
|
title: '卡密兑换',
|
||||||
icon: 'fa fas fa-ticket-alt',
|
icon: 'fa fas fa-ticket-alt',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'pay_balance_pass_charge_s', label: '启用余额卡密兑换', type: 'switch', default: false },
|
{ key: 'pay_balance_pass_charge_s', label: '启用卡密兑换余额功能', type: 'switch', default: false, help: '开启后用户可在余额充值弹窗选择「卡密」渠道,使用卡密直接兑换余额' },
|
||||||
{ key: 'pay_balance_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
{ key: 'pay_balance_pass_charge_desc', label: '卡密兑换余额说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_balance_pass_charge_s', value: true }, help: '用户使用卡密兑换余额时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||||
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
{ key: 'pay_balance_pass_charge_only_password', label: '余额卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true }, help: '余额卡密仅需输入密码,不需要卡号' },
|
||||||
|
{ key: 'points_pass_exchange_s', label: '启用卡密兑换积分功能', type: 'switch', default: false, help: '开启后用户可在购买积分弹窗选择「卡密」渠道,使用卡密直接兑换积分' },
|
||||||
|
{ key: 'points_pass_exchange_desc', label: '卡密兑换积分说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '用户使用卡密兑换积分时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||||
|
{ key: 'points_pass_exchange_only_password', label: '积分卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '积分卡密仅需输入密码,不需要卡号' },
|
||||||
|
{ key: 'pay_vip_pass_charge_s', label: '启用卡密兑换会员功能', type: 'switch', default: false, help: '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员' },
|
||||||
|
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||||
|
{ key: 'pay_vip_pass_charge_only_password', label: '会员卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'exchange',
|
key: 'points-exchange',
|
||||||
title: '余额兑换积分',
|
title: '余额兑换积分',
|
||||||
icon: 'fa fas fa-database',
|
icon: 'fa fas fa-database',
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 创作分成 — 现金/积分分成比例 + 创作分成说明与详情页配置。
|
||||||
|
*/
|
||||||
export const revenueSchema = {
|
export const revenueSchema = {
|
||||||
key: 'payment.revenue',
|
key: 'payment.revenue',
|
||||||
title: '创作中心',
|
title: '创作分成',
|
||||||
icon: 'fa fas fa-chart-line',
|
icon: 'fa fas fa-chart-line',
|
||||||
description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。',
|
description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。',
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -13,8 +16,25 @@ export const revenueSchema = {
|
|||||||
payment_revenue_points_ratio_user: 0,
|
payment_revenue_points_ratio_user: 0,
|
||||||
payment_revenue_points_ratio_vip_1: 0,
|
payment_revenue_points_ratio_vip_1: 0,
|
||||||
payment_revenue_points_ratio_vip_2: 0,
|
payment_revenue_points_ratio_vip_2: 0,
|
||||||
|
pay_income_desc_details: '<p>在本站发布付费内容,获得的收益将参与收入分成</p><p>普通用户分成比例为:60%</p><p>一级会员分成比例为:70%</p><p>二级会员分成比例为:80%</p><p>分成比例的计算方式为:(实收现金-推广佣金)x 分成比例</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>',
|
||||||
|
pay_income_desc_page_s: false,
|
||||||
|
pay_income_desc_page: 0,
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'notice',
|
||||||
|
title: '说明',
|
||||||
|
icon: 'fa fas fa-info-circle',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'revenue_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>开启创作分成后,用户发布的付费内容则按设置的比例收入与用户分成</li><li>在下方设置默认的分成比例,同时支持单独为每一个用户设置独立的分成比例</li><li>用户分成比例是按照订单实收现金并扣除推广佣金后,再按设置的比例分成</li><li>禁用此功能后,所有收款将不会与作者分成</li></ul>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
title: '创作中心设置',
|
title: '创作中心设置',
|
||||||
@@ -30,7 +50,7 @@ export const revenueSchema = {
|
|||||||
title: '现金收益分成',
|
title: '现金收益分成',
|
||||||
icon: 'fa fas fa-credit-card',
|
icon: 'fa fas fa-credit-card',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true }, help: '为0则不参与分成' },
|
||||||
{ key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
||||||
{ key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
||||||
],
|
],
|
||||||
@@ -40,10 +60,20 @@ export const revenueSchema = {
|
|||||||
title: '积分收益分成',
|
title: '积分收益分成',
|
||||||
icon: 'fa fas fa-database',
|
icon: 'fa fas fa-database',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true }, help: '为0则不参与分成(用户采用积分支付的订单给与作者的分成比例)' },
|
||||||
{ key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
||||||
{ key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
{ key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
title: '分成说明',
|
||||||
|
icon: 'fa fas fa-file-alt',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_income_desc_details', label: '创作分成说明', type: 'textarea', sanitize_type: 'html', default: '<p>在本站发布付费内容,获得的收益将参与收入分成</p><p>普通用户分成比例为:60%</p><p>一级会员分成比例为:70%</p><p>二级会员分成比例为:80%</p><p>分成比例的计算方式为:(实收现金-推广佣金)x 分成比例</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>', rows: 8, dependsOn: { key: 'pay_income_s', value: true }, help: '显示在用户中心的说明,建议为发布协议或者其他说明(必填项,内容不易太少),支持HTML代码' },
|
||||||
|
{ key: 'pay_income_desc_page_s', label: '创作分成详情页面', type: 'switch', default: false, dependsOn: { key: 'pay_income_s', value: true }, help: '您可以新建一个页面写入更加详细的创作分成说明及协议内容,再开启此项并选择对应的页面' },
|
||||||
|
{ key: 'pay_income_desc_page', label: '选择页面', type: 'page_select', default: 0, dependsOn: { key: 'pay_income_desc_page_s', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* VIP会员 — 结构 1:1 对照参考主题 Zibll:会员等级(开关)、会员权益
|
||||||
|
* (普通/一级/二级 tab)、会员购买选项(一级/二级/续费/升级 tab)、
|
||||||
|
* 积分兑换会员与界面文案。
|
||||||
|
*
|
||||||
|
* 会员商品 / 续费商品 / 升级价格 / 积分兑换明细均为结构化 group 或
|
||||||
|
* accordion,不再使用 `|` 分隔的 textarea。等级 Tab 标题通过 titleKey
|
||||||
|
* 绑定「会员显示名称」,改名即改 Tab 名。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIP_EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
|
||||||
|
|
||||||
|
const vipTagPalette = [
|
||||||
|
{ label: '#f74b3d', value: 'b-red' },
|
||||||
|
{ label: '#f3920a', value: 'b-yellow' },
|
||||||
|
{ label: '#08c4c1', value: 'b-cyan' },
|
||||||
|
{ label: '#0a8cf3', value: 'b-blue' },
|
||||||
|
{ label: '#1fd05a', value: 'b-green' },
|
||||||
|
{ label: '#c133f5', value: 'b-purple' },
|
||||||
|
{ label: '#121517', value: 'b-black' },
|
||||||
|
{ label: 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)', value: 'jb-red' },
|
||||||
|
{ label: 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)', value: 'jb-pink' },
|
||||||
|
{ label: 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)', value: 'jb-yellow' },
|
||||||
|
{ label: 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)', value: 'jb-cyan' },
|
||||||
|
{ label: 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)', value: 'jb-blue' },
|
||||||
|
{ label: 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)', value: 'jb-green' },
|
||||||
|
{ label: 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)', value: 'jb-purple' },
|
||||||
|
{ label: 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)', value: 'jb-vip1' },
|
||||||
|
{ label: 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)', value: 'jb-vip2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const vipUnitOptions = [
|
||||||
|
{ label: '天', value: 'day' },
|
||||||
|
{ label: '月', value: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
|
||||||
|
const vipProductGroupOptions = {
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
|
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。
|
||||||
|
const vipPointsGroupOptions = {
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
|
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
|
||||||
|
*/
|
||||||
|
function vipProductFields() {
|
||||||
|
return [
|
||||||
|
{ key: 'price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '699.00', min: 0, suffix: '元', help: '会员的实际购买价格' },
|
||||||
|
{ key: 'show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '999.00', min: 0, suffix: '元', help: '显示在执行价格前面,并划掉' },
|
||||||
|
{ key: 'tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '', rows: 1, help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: 'tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { key: 'tag', operator: 'not_empty' }, options: vipTagPalette },
|
||||||
|
{ key: 'time', label: '会员有效时间', type: 'spinner', default: 3, min: 0, max: 3600, step: 1, help: '开通会员的时长。填0则为永久会员' },
|
||||||
|
{ key: 'unit', label: '时长单位', type: 'radio', default: 'month', inline: true, dependsOn: { key: 'time', operator: 'truthy' }, options: vipUnitOptions },
|
||||||
|
{ key: '_time_permanent_hint', label: '', type: 'alert', alertType: 'success', ui_only: true, dependsOn: { key: 'time', operator: 'eq', value: 0 }, content: '会员有效时间已设置为:永久会员' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员等级 Tab 字段集(参考主题 CFS_Module::vip_tab,显示名称并入 Tab)。
|
||||||
|
*/
|
||||||
|
function vipLevelTabFields(level) {
|
||||||
|
const dep = { key: `pay_user_vip_${level}_s`, value: true };
|
||||||
|
return [
|
||||||
|
{ key: `pay_user_vip_${level}_name`, label: '会员显示名称', type: 'input', sanitize_type: 'html', default: level === 1 ? '黄金会员' : '钻石会员', dependsOn: dep, help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: `pay_user_vip_${level}_desc`, label: '一句话简介', type: 'input', sanitize_type: 'html', default: '尊享专属特权', dependsOn: dep, help: '支持HTML' },
|
||||||
|
{ key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html', default: VIP_EQUITY_DEFAULT, rows: 4, dependsOn: dep, help: '支持安全HTML,建议使用 <li> 列表项' },
|
||||||
|
{ key: `vip_${level}_product_s`, label: '购买会员', type: 'switch', default: true, dependsOn: dep, help: '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能' },
|
||||||
|
{
|
||||||
|
key: `vip_${level}_product`,
|
||||||
|
label: '会员商品',
|
||||||
|
type: 'group',
|
||||||
|
max: 8,
|
||||||
|
button_title: '添加会员商品',
|
||||||
|
default: [
|
||||||
|
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
|
...vipProductGroupOptions,
|
||||||
|
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
|
||||||
|
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
||||||
|
fields: vipProductFields(),
|
||||||
|
},
|
||||||
|
{ key: `vip_${level}_img_icon`, label: '会员图标', type: 'upload', default: '', dependsOn: dep, help: `留空使用内置 vip-${level}.svg 图标;建议尺寸300x300` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const vipSchema = {
|
export const vipSchema = {
|
||||||
key: 'payment.vip',
|
key: 'payment.vip',
|
||||||
title: 'VIP会员',
|
title: 'VIP会员',
|
||||||
icon: 'fa fas fa-gem',
|
icon: 'fa fas fa-gem',
|
||||||
description: '对齐 legacy 双级会员:分档套餐、权益、续费/升级与积分兑换。',
|
description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
|
||||||
defaults: {
|
defaults: {
|
||||||
nav_pay_vip: true,
|
nav_pay_vip: true,
|
||||||
nav_user_pay_vip: true,
|
nav_user_pay_vip: true,
|
||||||
pay_user_vip_1_s: true,
|
pay_user_vip_1_s: true,
|
||||||
pay_user_vip_1_name: '一级会员',
|
pay_user_vip_1_name: '黄金会员',
|
||||||
pay_user_vip_1_desc: '尊享一级会员特权',
|
pay_user_vip_1_desc: '尊享专属特权',
|
||||||
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>',
|
pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
|
||||||
vip_1_product_s: true,
|
vip_1_product_s: true,
|
||||||
payment_vip_1_products: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
|
vip_1_product: [
|
||||||
|
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
vip_1_img_icon: '',
|
vip_1_img_icon: '',
|
||||||
pay_user_vip_2_s: true,
|
pay_user_vip_2_s: true,
|
||||||
pay_user_vip_2_name: '二级会员',
|
pay_user_vip_2_name: '钻石会员',
|
||||||
pay_user_vip_2_desc: '尊享二级会员特权',
|
pay_user_vip_2_desc: '尊享专属特权',
|
||||||
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>',
|
pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
|
||||||
vip_2_product_s: true,
|
vip_2_product_s: true,
|
||||||
payment_vip_2_products: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
|
vip_2_product: [
|
||||||
|
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
vip_2_img_icon: '',
|
vip_2_img_icon: '',
|
||||||
pay_user_vip_desc: '',
|
pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
|
||||||
pay_user_vip_more: '',
|
pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
|
||||||
pay_download_limit: 0,
|
pay_download_limit: 0,
|
||||||
|
pay_download_speed: 0,
|
||||||
pay_download_limit_vip_1: 0,
|
pay_download_limit_vip_1: 0,
|
||||||
|
pay_download_speed_vip_1: 0,
|
||||||
pay_download_limit_vip_2: 0,
|
pay_download_limit_vip_2: 0,
|
||||||
pay_vip_pass_charge_s: false,
|
pay_download_speed_vip_2: 0,
|
||||||
pay_vip_pass_charge_desc: '',
|
vip_1_product_s: true,
|
||||||
pay_vip_pass_charge_only_password: true,
|
|
||||||
pay_vip_points_exchange_s: false,
|
|
||||||
pay_vip_points_exchange_desc: '',
|
|
||||||
payment_vip_points_packages: '1|500|30|积分月卡\n1|5000|0|积分永久',
|
|
||||||
vip_renew: true,
|
vip_renew: true,
|
||||||
vip_renew_desc: '',
|
vip_renew_desc: '立即续费会员,畅享VIP权益',
|
||||||
vip_renew_price_type: 'discount',
|
vip_renew_price_type: 'discount',
|
||||||
vip_renew_discount: 8,
|
vip_renew_discount: 8,
|
||||||
vip_renew_reduce: '20.00',
|
vip_renew_reduce: '20.00',
|
||||||
|
vip_1_renew_product: [
|
||||||
|
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
|
vip_2_renew_product: [
|
||||||
|
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
|
vip_2_product_s: true,
|
||||||
|
vip_renew: true,
|
||||||
|
vip_renew_discount: 8,
|
||||||
|
vip_renew_reduce: '20.00',
|
||||||
vip_upgrade: true,
|
vip_upgrade: true,
|
||||||
vip_upgrade_desc: '',
|
vip_upgrade_desc: '升级VIP会员,享更多会员权益',
|
||||||
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
|
vip_upgrade_product: {
|
||||||
|
month_to_month_s: true,
|
||||||
|
unit_price: '10.00',
|
||||||
|
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
|
||||||
|
unit_tag_class: 'jb-yellow',
|
||||||
|
jump_s: false,
|
||||||
|
jump_price: '199.00',
|
||||||
|
jump_show_price: '299.00',
|
||||||
|
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||||
|
jump_tag_class: 'jb-red',
|
||||||
|
permanent_price: '199.00',
|
||||||
|
permanent_show_price: '299.00',
|
||||||
|
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||||
|
permanent_tag_class: 'jb-red',
|
||||||
|
},
|
||||||
|
pay_vip_points_exchange_s: false,
|
||||||
|
pay_vip_points_exchange_product: [
|
||||||
|
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||||
|
{ points: 5000, level: 1, time: 'Permanent' },
|
||||||
|
],
|
||||||
|
pay_vip_points_exchange_desc: '',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'notice',
|
||||||
|
title: '说明',
|
||||||
|
icon: 'fa fas fa-info-circle',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'vip_notice',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>开启付费会员功能之前,请先配置好收款接口,确保网站收款正常</li><li>会员功能的设置项目较多,请仔细核对,避免出现价格、时间的问题</li><li>配合设置名称、有效期等可搭配出不同类型的会员</li><li>管理员可在后台为用户单独开启会员</li></ul>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
title: '会员等级',
|
title: '会员等级',
|
||||||
@@ -49,100 +193,306 @@ export const vipSchema = {
|
|||||||
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
|
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
|
||||||
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
|
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
|
||||||
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
|
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
|
||||||
{ key: 'pay_user_vip_1_name', label: '一级会员名称', type: 'input', default: '一级会员', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
{
|
||||||
{ key: 'pay_user_vip_1_desc', label: '一级会员一句话简介', type: 'input', default: '尊享一级会员特权', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
key: 'vip_level_order_notice',
|
||||||
{ key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', default: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '建议使用 <li> 列表项' },
|
label: '',
|
||||||
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' },
|
type: 'notice',
|
||||||
|
ui_only: true,
|
||||||
|
alertType: 'danger',
|
||||||
|
dependsOn: { all: [{ key: 'pay_user_vip_2_s', value: true }, { key: 'pay_user_vip_1_s', operator: 'falsy' }] },
|
||||||
|
content: '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>',
|
||||||
|
},
|
||||||
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
|
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
|
||||||
{ key: 'pay_user_vip_2_name', label: '二级会员名称', type: 'input', default: '二级会员', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
|
||||||
{ key: 'pay_user_vip_2_desc', label: '二级会员一句话简介', type: 'input', default: '尊享二级会员特权', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
|
||||||
{ key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', default: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
|
||||||
{ key: 'vip_2_img_icon', label: '二级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '留空使用内置 vip-2.svg 图标' },
|
|
||||||
{ key: 'pay_user_vip_desc', label: '弹窗顶部会员说明', type: 'textarea', default: '' },
|
|
||||||
{ key: 'pay_user_vip_more', label: '弹窗底部补充说明 HTML', type: 'textarea', default: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'packages',
|
|
||||||
title: '分档套餐',
|
|
||||||
icon: 'fa fas fa-gem',
|
|
||||||
fields: [
|
|
||||||
{ key: 'vip_1_product_s', label: '允许购买一级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
|
||||||
{
|
|
||||||
key: 'payment_vip_1_products',
|
|
||||||
label: '一级会员套餐',
|
|
||||||
type: 'textarea',
|
|
||||||
default: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
|
|
||||||
dependsOn: { key: 'vip_1_product_s', value: true },
|
|
||||||
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
|
|
||||||
},
|
|
||||||
{ key: 'vip_2_product_s', label: '允许购买二级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
|
||||||
{
|
|
||||||
key: 'payment_vip_2_products',
|
|
||||||
label: '二级会员套餐',
|
|
||||||
type: 'textarea',
|
|
||||||
default: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
|
|
||||||
dependsOn: { key: 'vip_2_product_s', value: true },
|
|
||||||
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'points_exchange',
|
|
||||||
title: '积分兑换会员',
|
|
||||||
icon: 'fa fas fa-database',
|
|
||||||
fields: [
|
|
||||||
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false },
|
|
||||||
{
|
|
||||||
key: 'payment_vip_points_packages',
|
|
||||||
label: '兑换套餐',
|
|
||||||
type: 'textarea',
|
|
||||||
default: '1|500|30|积分月卡\n1|5000|0|积分永久',
|
|
||||||
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
|
|
||||||
help: '每行:等级|积分|天数|标题(天数 0=永久;仅未开通会员可兑)',
|
|
||||||
},
|
|
||||||
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_points_exchange_s', value: true } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'benefit',
|
key: 'benefit',
|
||||||
title: '下载权益',
|
title: '会员权益',
|
||||||
icon: 'fa fas fa-gift',
|
icon: 'fa fas fa-gift',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'pay_download_limit', label: '普通用户下载限制', type: 'number', default: 0, min: 0, max: 9999, help: '0为不限制' },
|
|
||||||
{ key: 'pay_download_limit_vip_1', label: '一级会员下载限制', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
|
||||||
{ key: 'pay_download_limit_vip_2', label: '二级会员下载限制', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
|
||||||
{ key: 'pay_vip_pass_charge_s', label: '会员卡密兑换', type: 'switch', default: false },
|
|
||||||
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_pass_charge_s', value: true } },
|
|
||||||
{ key: 'pay_vip_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'renew',
|
|
||||||
title: '续费设置',
|
|
||||||
icon: 'fa fas fa-sync-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'vip_renew', label: '允许续费', type: 'switch', default: true },
|
|
||||||
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } },
|
|
||||||
{ key: 'vip_renew_price_type', label: '续费价格类型', type: 'radio', default: 'discount', options: [{ label: '相同价格', value: 'same' }, { label: '折扣价格', value: 'discount' }, { label: '优惠立减', value: 'reduce' }], dependsOn: { key: 'vip_renew', value: true } },
|
|
||||||
{ key: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } },
|
|
||||||
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'input', sanitize_type: 'money_decimal', default: '20.00', min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'upgrade',
|
|
||||||
title: '升级设置',
|
|
||||||
icon: 'fa fas fa-arrow-up',
|
|
||||||
fields: [
|
|
||||||
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
|
|
||||||
{ key: 'vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'vip_upgrade', value: true } },
|
|
||||||
{
|
{
|
||||||
key: 'vip_upgrade_product',
|
key: 'vip_benefit',
|
||||||
label: '升级产品',
|
label: '会员权益',
|
||||||
|
type: 'tabbed',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'ordinary',
|
||||||
|
title: '普通用户',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_download_limit', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, help: '普通用户每日最多可下载几个免费资源(为0则不限制)' },
|
||||||
|
{ key: 'pay_download_speed', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip1',
|
||||||
|
title: '一级会员',
|
||||||
|
titleKey: 'pay_user_vip_1_name',
|
||||||
|
titleFallback: '一级会员',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_download_limit_vip_1', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '一级会员每日最多可下载几个免费资源(为0则不限制)' },
|
||||||
|
{ key: 'pay_download_speed_vip_1', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip2',
|
||||||
|
title: '二级会员',
|
||||||
|
titleKey: 'pay_user_vip_2_name',
|
||||||
|
titleFallback: '二级会员',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_download_limit_vip_2', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '二级会员每日最多可下载几个免费资源(为0则不限制)' },
|
||||||
|
{ key: 'pay_download_speed_vip_2', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'opt',
|
||||||
|
title: '会员购买选项',
|
||||||
|
icon: 'fa fas fa-shopping-cart',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'vip_opt',
|
||||||
|
label: '会员购买选项',
|
||||||
|
type: 'tabbed',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'vip1',
|
||||||
|
title: '一级会员',
|
||||||
|
titleKey: 'pay_user_vip_1_name',
|
||||||
|
titleFallback: '一级会员',
|
||||||
|
fields: [
|
||||||
|
...vipLevelTabFields(1),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip2',
|
||||||
|
title: '二级会员',
|
||||||
|
titleKey: 'pay_user_vip_2_name',
|
||||||
|
titleFallback: '二级会员',
|
||||||
|
fields: [
|
||||||
|
...vipLevelTabFields(2),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'renew',
|
||||||
|
title: '会员续费',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'vip_renew',
|
||||||
|
label: '允许续费',
|
||||||
|
type: 'switch',
|
||||||
|
default: true,
|
||||||
|
help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_renew_desc',
|
||||||
|
label: '续费介绍',
|
||||||
|
type: 'input',
|
||||||
|
sanitize_type: 'html',
|
||||||
|
default: '立即续费会员,畅享VIP权益',
|
||||||
|
dependsOn: { key: 'vip_renew', value: true },
|
||||||
|
help: '续费会员的一句话简介,支持HTML',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_renew_price_type',
|
||||||
|
label: '续费价格类型',
|
||||||
|
type: 'select',
|
||||||
|
default: 'discount',
|
||||||
|
dependsOn: { key: 'vip_renew', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: '相同价格', value: 'same' },
|
||||||
|
{ label: '折扣价格', value: 'discount' },
|
||||||
|
{ label: '优惠立减', value: 'reduce' },
|
||||||
|
{ label: '自定义商品', value: 'customize' },
|
||||||
|
],
|
||||||
|
help: '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_renew_discount',
|
||||||
|
label: '续费折扣',
|
||||||
|
type: 'slider',
|
||||||
|
sanitize_type: 'float',
|
||||||
|
default: 8,
|
||||||
|
min: 0.1,
|
||||||
|
max: 9.9,
|
||||||
|
step: 0.1,
|
||||||
|
unit: '折',
|
||||||
|
dependsOn: { key: 'vip_renew_price_type', value: 'discount' },
|
||||||
|
help: '在会员商品基础上打几折,如 8 = 八折',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_renew_reduce',
|
||||||
|
label: '续费减免金额',
|
||||||
|
type: 'spinner',
|
||||||
|
sanitize_type: 'money_decimal',
|
||||||
|
default: '20.00',
|
||||||
|
min: 0,
|
||||||
|
max: 500,
|
||||||
|
step: 5,
|
||||||
|
unit: '元',
|
||||||
|
dependsOn: { key: 'vip_renew_price_type', value: 'reduce' },
|
||||||
|
help: '在会员商品基础上优惠的金额,优惠后总金额不能为0元',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_1_renew_product',
|
||||||
|
label: '一级会员续费',
|
||||||
|
type: 'group',
|
||||||
|
max: 8,
|
||||||
|
button_title: '添加续费商品',
|
||||||
|
default: [
|
||||||
|
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
|
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||||
|
help: '一级会员续费的商品选项',
|
||||||
|
...vipProductGroupOptions,
|
||||||
|
fields: vipProductFields(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vip_2_renew_product',
|
||||||
|
label: '二级会员续费',
|
||||||
|
type: 'group',
|
||||||
|
max: 8,
|
||||||
|
button_title: '添加续费商品',
|
||||||
|
default: [
|
||||||
|
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||||
|
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||||
|
],
|
||||||
|
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||||
|
help: '二级会员续费的商品选项',
|
||||||
|
...vipProductGroupOptions,
|
||||||
|
fields: vipProductFields(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upgrade',
|
||||||
|
title: '会员升级',
|
||||||
|
fields: [
|
||||||
|
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
|
||||||
|
{ key: 'vip_upgrade_desc', label: '升级介绍', type: 'input', sanitize_type: 'html', default: '升级VIP会员,享更多会员权益', dependsOn: { key: 'vip_upgrade', value: true }, help: '升级会员的一句话简介,支持HTML' },
|
||||||
|
{
|
||||||
|
key: 'vip_upgrade_product',
|
||||||
|
label: '升级价格',
|
||||||
|
type: 'accordion',
|
||||||
|
default: {
|
||||||
|
month_to_month_s: true,
|
||||||
|
unit_price: '10.00',
|
||||||
|
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
|
||||||
|
unit_tag_class: 'jb-yellow',
|
||||||
|
jump_s: false,
|
||||||
|
jump_price: '199.00',
|
||||||
|
jump_show_price: '299.00',
|
||||||
|
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||||
|
jump_tag_class: 'jb-red',
|
||||||
|
permanent_price: '199.00',
|
||||||
|
permanent_show_price: '299.00',
|
||||||
|
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||||
|
permanent_tag_class: 'jb-red',
|
||||||
|
},
|
||||||
|
dependsOn: { key: 'vip_upgrade', value: true },
|
||||||
|
help: '会员升级的商品选项',
|
||||||
|
sections: [
|
||||||
|
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
|
||||||
|
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
|
||||||
|
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
|
||||||
|
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
|
||||||
|
{ key: 'unit_price', label: '天单价', type: 'number', sanitize_type: 'money_decimal', default: '10.00', min: 0, unit: '元', dependsOn: { key: 'month_to_month_s', value: true } },
|
||||||
|
{ key: 'unit_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', rows: 1, dependsOn: { key: 'month_to_month_s', value: true }, help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: 'unit_tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { all: [{ key: 'month_to_month_s', value: true }, { key: 'unit_tag', operator: 'not_empty' }] }, options: vipTagPalette },
|
||||||
|
{ key: '_upgrade_jump_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项' },
|
||||||
|
{ key: 'jump_s', label: '月费会员升级永久会员', type: 'switch', default: false },
|
||||||
|
{ key: 'jump_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '月费会员升级永久会员的价格' },
|
||||||
|
{ key: 'jump_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '显示在执行价格前面,并划掉' },
|
||||||
|
{ key: 'jump_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, dependsOn: { key: 'jump_s', value: true }, help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: 'jump_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { all: [{ key: 'jump_s', value: true }, { key: 'jump_tag', operator: 'not_empty' }] }, options: vipTagPalette },
|
||||||
|
{ key: 'permanent_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', help: '永久会员升级永久会员的价格' },
|
||||||
|
{ key: 'permanent_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', help: '显示在执行价格前面,并划掉' },
|
||||||
|
{ key: 'permanent_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, help: '支持HTML,请注意控制长度' },
|
||||||
|
{ key: 'permanent_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { key: 'permanent_tag', operator: 'not_empty' }, options: vipTagPalette },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'points-exchange',
|
||||||
|
title: '积分兑换会员',
|
||||||
|
icon: 'fa fas fa-database',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false, help: '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费' },
|
||||||
|
{
|
||||||
|
key: 'pay_vip_points_exchange_product',
|
||||||
|
label: '积分兑换明细',
|
||||||
|
type: 'group',
|
||||||
|
max: 16,
|
||||||
|
button_title: '添加积分兑换商品',
|
||||||
|
default: [
|
||||||
|
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||||
|
{ points: 5000, level: 1, time: 'Permanent' },
|
||||||
|
],
|
||||||
|
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
|
||||||
|
help: '每一行为一个可兑换的会员套餐',
|
||||||
|
...vipPointsGroupOptions,
|
||||||
|
fields: [
|
||||||
|
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
label: '兑换会员',
|
||||||
|
type: 'radio',
|
||||||
|
default: 1,
|
||||||
|
inline: true,
|
||||||
|
options: [
|
||||||
|
{ label: '一级会员', value: 1 },
|
||||||
|
{ label: '二级会员', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'time', label: '兑换时长', type: 'input', default: '', help: '填 Permanent 为永久' },
|
||||||
|
{
|
||||||
|
key: 'unit',
|
||||||
|
label: '时长单位',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'day',
|
||||||
|
inline: true,
|
||||||
|
dependsOn: { key: 'time', operator: 'neq', value: 'Permanent' },
|
||||||
|
options: vipUnitOptions,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_points_exchange_s', value: true }, help: '显示在积分兑换弹窗底部,可以为提醒事项、兑换协议等,支持HTML代码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'modal-copy',
|
||||||
|
title: '界面文案',
|
||||||
|
icon: 'fa fas fa-align-left',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'pay_user_vip_desc',
|
||||||
|
label: '会员介绍',
|
||||||
|
type: 'input',
|
||||||
|
sanitize_type: 'html',
|
||||||
|
default: '开通VIP会员,享受会员专属折扣以及多项特权',
|
||||||
|
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
|
||||||
|
help: '显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍,支持HTML',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pay_user_vip_more',
|
||||||
|
label: '开通会员更多内容',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
|
sanitize_type: 'html',
|
||||||
dependsOn: { key: 'vip_upgrade', value: true },
|
default: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
|
||||||
help: '每行一条。永久VIP1升级:permanent|价格|标题;按剩余天:unit|每日单价|标题;限时跳转永久:jump|价格|标题',
|
rows: 4,
|
||||||
|
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
|
||||||
|
help: '显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,29 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 提现策略 — 提现门槛、每日限制、收款渠道与提现协议。
|
||||||
|
*/
|
||||||
export const withdrawalSchema = {
|
export const withdrawalSchema = {
|
||||||
key: 'payment.withdrawal',
|
key: 'payment.withdrawal',
|
||||||
title: '提现策略',
|
title: '提现策略',
|
||||||
icon: 'fa fas fa-money-bill',
|
icon: 'fa fas fa-money-bill',
|
||||||
description: '配置提现门槛、手续费比例与每日限制。',
|
description: '配置提现门槛、手续费比例、每日限制、收款渠道与提现协议。',
|
||||||
defaults: {
|
defaults: {
|
||||||
affiliate_min_withdrawal: '50.00',
|
affiliate_min_withdrawal: '50.00',
|
||||||
affiliate_withdrawal_fee: 5,
|
affiliate_withdrawal_fee: 5,
|
||||||
affiliate_daily_withdrawal_count: 3,
|
affiliate_daily_withdrawal_count: 3,
|
||||||
affiliate_daily_withdrawal_limit: '500.00',
|
affiliate_daily_withdrawal_limit: '500.00',
|
||||||
affiliate_withdrawal_reset_time: '00:00',
|
affiliate_withdrawal_reset_time: '00:00',
|
||||||
affiliate_payout_channels: ['alipay', 'wechat'],
|
affiliate_payout_channels: ['alipay', 'wechat'],
|
||||||
|
pay_rebate_withdraw_text_details: '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
title: '提现设置',
|
title: '提现门槛',
|
||||||
icon: 'fa fas fa-sliders-h',
|
icon: 'fa fas fa-sliders-h',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'affiliate_min_withdrawal', label: '最低提现金额', type: 'input', sanitize_type: 'money_decimal', default: '50.00', help: '仅接受最多两位小数的十进制字符串。' },
|
{ key: 'affiliate_min_withdrawal', label: '提现最低金额限制', type: 'input', sanitize_type: 'money_decimal', default: '50.00', help: '当用户可提现总金额(推广佣金、余额、创作分成)高于多少时候,才能发起提现' },
|
||||||
{ key: 'affiliate_withdrawal_fee', label: '提现手续费(%)', type: 'number', default: 5, min: 0, max: 100 },
|
{ key: 'affiliate_withdrawal_fee', label: '提现手续费(%)', type: 'number', default: 5, min: 0, max: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'limits',
|
||||||
|
title: '每日限制',
|
||||||
|
icon: 'fa fas fa-clock',
|
||||||
|
fields: [
|
||||||
{ key: 'affiliate_daily_withdrawal_count', label: '每日可提现次数', type: 'number', default: 3, min: 0, max: 99999 },
|
{ key: 'affiliate_daily_withdrawal_count', label: '每日可提现次数', type: 'number', default: 3, min: 0, max: 99999 },
|
||||||
{ key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'input', sanitize_type: 'money_decimal', default: '500.00', help: '仅接受最多两位小数的十进制字符串。' },
|
{ key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'input', sanitize_type: 'money_decimal', default: '500.00' },
|
||||||
{ key: 'affiliate_withdrawal_reset_time', label: '重置时间', type: 'input', default: '00:00', help: '按站点 WordPress 时区重置,格式 HH:MM。' },
|
{ key: 'affiliate_withdrawal_reset_time', label: '重置时间', type: 'input', default: '00:00', help: '按站点 WordPress 时区重置,格式 HH:MM。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'channels',
|
||||||
|
title: '收款渠道',
|
||||||
|
icon: 'fa fas fa-qrcode',
|
||||||
|
fields: [
|
||||||
{ key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] },
|
{ key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'agreement',
|
||||||
|
title: '提现协议',
|
||||||
|
icon: 'fa fas fa-file-contract',
|
||||||
|
fields: [
|
||||||
|
{ key: 'pay_rebate_withdraw_text_details', label: '提现协议', type: 'textarea', sanitize_type: 'html', default: '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>', rows: 4, help: '用户申请提现时展示的内容,建议为提现须知等,支持HTML代码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,30 @@
|
|||||||
* Section keys must stay aligned with the router and PHP schema provider.
|
* Section keys must stay aligned with the router and PHP schema provider.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User type options shared by the upload limit repeaters.
|
||||||
|
* Mirrors AKSRT\Attachment\UploadLimits::user_type_options() (levels 2-10, VIP 1-2).
|
||||||
|
*/
|
||||||
|
const uploadUserTypeOptions = [
|
||||||
|
{ label: '最小默认值(所有人)', value: 'default' },
|
||||||
|
{ label: '已登录用户', value: 'logged' },
|
||||||
|
...[2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => ({ label: `等级达到 ${level} 级`, value: `level_${level}` })),
|
||||||
|
{ label: 'VIP1 会员', value: 'vip_1' },
|
||||||
|
{ label: 'VIP2 会员', value: 'vip_2' },
|
||||||
|
{ label: '已认证用户', value: 'auth' },
|
||||||
|
{ label: '超级管理员', value: 'admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const uploadMimeUserTypeOptions = [
|
||||||
|
{ label: '所有用户', value: 'all' },
|
||||||
|
...uploadUserTypeOptions,
|
||||||
|
]
|
||||||
|
|
||||||
|
const uploadSizeFields = [
|
||||||
|
{ key: 'type', label: '用户类型', type: 'select', default: 'logged', options: uploadUserTypeOptions },
|
||||||
|
{ key: 'val', label: '可上传大小', type: 'number', default: 30, min: 1, max: 2048, unit: 'M' },
|
||||||
|
]
|
||||||
|
|
||||||
export const permissionsSchemas = {
|
export const permissionsSchemas = {
|
||||||
/**
|
/**
|
||||||
* Login / captcha / access guard settings
|
* Login / captcha / access guard settings
|
||||||
@@ -60,6 +84,9 @@ export const permissionsSchemas = {
|
|||||||
{ label: '资源下载', value: 'download' },
|
{ label: '资源下载', value: 'download' },
|
||||||
{ label: '每日签到', value: 'signin' },
|
{ label: '每日签到', value: 'signin' },
|
||||||
{ label: '前台投稿', value: 'front_submit' },
|
{ label: '前台投稿', value: 'front_submit' },
|
||||||
|
{ label: '打赏用户', value: 'tip_user' },
|
||||||
|
{ label: '申请售后', value: 'after_sale_apply' },
|
||||||
|
{ label: '撤销售后', value: 'after_sale_cancel' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -188,83 +215,106 @@ export const permissionsSchemas = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attachment permissions
|
* Attachment / upload governance
|
||||||
*/
|
*/
|
||||||
attachment: {
|
attachment: {
|
||||||
key: 'permissions.attachment',
|
key: 'permissions.attachment',
|
||||||
title: '附件权限',
|
title: '附件配置',
|
||||||
icon: 'fa fas fa-paperclip',
|
icon: 'fa fas fa-paperclip',
|
||||||
description: '控制文件上传和附件下载权限。',
|
description: '统一管理上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。',
|
||||||
defaults: {
|
defaults: {
|
||||||
avatar_max_size: 2,
|
upload_img_size: [
|
||||||
submit_image_max_size: 5,
|
{ type: 'default', val: 5 },
|
||||||
comment_image_max_size: 2,
|
{ type: 'admin', val: 20 },
|
||||||
resource_max_size: 50,
|
],
|
||||||
},
|
image_upload_multiple: 6,
|
||||||
groups: [
|
upload_video_size: [
|
||||||
{
|
{ type: 'default', val: 30 },
|
||||||
key: 'profile',
|
{ type: 'admin', val: 2048 },
|
||||||
title: '用户资料卡背景图',
|
],
|
||||||
icon: 'fa fas fa-user-circle',
|
upload_file_size: [
|
||||||
fields: [
|
{ type: 'default', val: 30 },
|
||||||
{ key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 },
|
{ type: 'admin', val: 2048 },
|
||||||
],
|
],
|
||||||
},
|
upload_file_mimes: [
|
||||||
{
|
{ type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' },
|
||||||
key: 'content',
|
{ type: 'all', pattern: 'allow', val: '' },
|
||||||
title: '内容上传',
|
|
||||||
icon: 'fa fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'submit_image_max_size', label: '投稿图片大小上限 (MB)', type: 'number', min: 1, max: 20 },
|
|
||||||
{ key: 'comment_image_max_size', label: '评论图片大小上限 (MB)', type: 'number', min: 1, max: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resource',
|
|
||||||
title: '资源附件',
|
|
||||||
icon: 'fa fas fa-file-archive',
|
|
||||||
fields: [
|
|
||||||
{ key: 'resource_max_size', label: '资源附件大小上限 (MB)', type: 'number', min: 1, max: 200 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
groups: [
|
||||||
/**
|
{
|
||||||
* Download link configuration
|
key: 'size-limits',
|
||||||
*/
|
title: '上传大小限制',
|
||||||
download: {
|
icon: 'fa fas fa-weight-hanging',
|
||||||
key: 'permissions.download',
|
description: '设置上传大小,请考虑服务器负荷、硬盘,以及服务器最大能支持的范围。实际上传大小取「主题限制」与「PHP 环境限制」的较小值,超过 PHP 环境限制时请调整 php.ini 的 upload_max_filesize 与 post_max_size。',
|
||||||
title: '下载配置',
|
fields: [
|
||||||
icon: 'fa fas fa-download',
|
{
|
||||||
description: '下载总开关与链接别名、默认使用次数、有效期限规则。',
|
key: 'upload_img_size',
|
||||||
defaults: {
|
label: '前端图片上传限制',
|
||||||
enabled: true,
|
type: 'repeater',
|
||||||
url_alias: 'download',
|
min: 1,
|
||||||
default_max_uses: 1,
|
addText: '添加细分用户权限',
|
||||||
ttl_minutes: 10,
|
help: '投稿、评论、头像与资料卡背景图等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。',
|
||||||
|
fields: uploadSizeFields.map((field) => ({ ...field, default: field.key === 'val' ? 5 : field.default })),
|
||||||
|
},
|
||||||
|
{ key: 'image_upload_multiple', label: '图片批量上传最大数量', type: 'number', min: 0, max: 100, unit: '张', help: '前端投稿、评论上传图片允许的单次批量上传数量(为 0 则不限制,为 1 则不能批量上传)。' },
|
||||||
|
{
|
||||||
|
key: 'upload_video_size',
|
||||||
|
label: '前端视频上传限制',
|
||||||
|
type: 'repeater',
|
||||||
|
min: 1,
|
||||||
|
addText: '添加细分用户权限',
|
||||||
|
help: '前端允许上传的最大视频大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
|
||||||
|
fields: uploadSizeFields,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upload_file_size',
|
||||||
|
label: '前端文件上传大小限制',
|
||||||
|
type: 'repeater',
|
||||||
|
min: 1,
|
||||||
|
addText: '添加细分用户权限',
|
||||||
|
help: '前端允许上传的其他文件(不含视频和图片,建议大于视频图片)最大大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
|
||||||
|
fields: uploadSizeFields,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
groups: [
|
{
|
||||||
{
|
key: 'mime-limits',
|
||||||
key: 'basic',
|
title: '上传格式限制',
|
||||||
title: '下载总开关',
|
icon: 'fa fas fa-file-code',
|
||||||
icon: 'fa fas fa-power-off',
|
fields: [
|
||||||
fields: [
|
{
|
||||||
{ key: 'enabled', label: '启用本站下载链接', type: 'switch', help: '总开关:开启后下载通过本站中转链接;关闭后下载按钮直接使用原始下载地址(直连),下方链接规则全部不生效,旧的中转链接同步失效。' },
|
key: 'mime_limit_notice',
|
||||||
],
|
label: '安全提示',
|
||||||
},
|
type: 'notice',
|
||||||
{
|
alertType: 'warning',
|
||||||
key: 'rules',
|
alertContent: '请添加文件的扩展名和 MIME 类型,用英文逗号分割。格式示例:mp3=audio/mpeg,jpg=image/jpeg。禁止的优先级大于允许。郑重提示:请勿允许上传 php、sh、exe 等可执行文件,会极大增加服务器被入侵攻击的风险。',
|
||||||
title: '链接规则',
|
},
|
||||||
icon: 'fa fas fa-link',
|
{
|
||||||
description: '文件完整送达才计次(HEAD 探测、传输中断不计次);到期或耗尽后访问旧地址返回 410,需重新获取,不会自动续签。',
|
key: 'upload_file_mimes',
|
||||||
fields: [
|
label: '前端上传文件格式限制',
|
||||||
{ key: 'url_alias', label: '下载链接 URL 别名', type: 'input', placeholder: 'download', required: true, dependsOn: { key: 'enabled', value: true }, help: '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效。' },
|
type: 'repeater',
|
||||||
{ key: 'default_max_uses', label: '下载链接默认使用次数', type: 'number', min: 1, max: 100, dependsOn: { key: 'enabled', value: true }, help: '单个链接可完成下载的次数;耗尽后链接返回 410,需重新获取。' },
|
min: 1,
|
||||||
{ key: 'ttl_minutes', label: '下载链接默认有效期限(分钟)', type: 'number', min: 1, max: 1440, dependsOn: { key: 'enabled', value: true }, help: '链接签发后超过该时长即失效并返回 410,不自动续期。' },
|
addText: '添加细分用户权限',
|
||||||
],
|
help: '根据不同的用户类型,添加额外允许或禁止上传的文件格式(禁止的优先级大于允许)。',
|
||||||
},
|
fields: [
|
||||||
],
|
{ key: 'type', label: '用户类型', type: 'select', default: 'all', options: uploadMimeUserTypeOptions },
|
||||||
},
|
{
|
||||||
|
key: 'pattern',
|
||||||
|
label: '规则',
|
||||||
|
type: 'select',
|
||||||
|
default: 'allow',
|
||||||
|
options: [
|
||||||
|
{ label: '允许', value: 'allow' },
|
||||||
|
{ label: '禁止', value: 'prohibit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'val', label: '格式', type: 'textarea', default: '', rows: 1, placeholder: '如:svg=image/svg+xml,mp3=audio/mpeg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -2,101 +2,80 @@ export const commentSchema = {
|
|||||||
key: 'post_list.comment',
|
key: 'post_list.comment',
|
||||||
title: '评论设置',
|
title: '评论设置',
|
||||||
icon: 'fa fas fa-comment-dots',
|
icon: 'fa fas fa-comment-dots',
|
||||||
description: '配置评论区的排序、分页、权限、工具栏与互动方式。',
|
description: '配置评论模块的显隐条件、翻页模式、输入工具栏与互动方式。',
|
||||||
defaults: {
|
defaults: {
|
||||||
comment_order: 'asc',
|
close_comments: false,
|
||||||
comment_per_page: 20,
|
comment_box_hide: '',
|
||||||
comment_pagination_type: 'numbered',
|
comment_paginate_type: 'default',
|
||||||
comment_paging_ajax_ias_s: true,
|
comment_paging_ajax_ias_s: true,
|
||||||
comment_paging_ajax_ias_max: 3,
|
comment_paging_ajax_ias_max: 3,
|
||||||
comment_nested_depth: 3,
|
|
||||||
comment_avatar_size: 48,
|
|
||||||
comment_close_comments: false,
|
|
||||||
comment_edit_window: 10,
|
|
||||||
comment_code: true,
|
comment_code: true,
|
||||||
comment_img: true,
|
comment_img: true,
|
||||||
|
comment_upload_img: false,
|
||||||
comment_link: false,
|
comment_link: false,
|
||||||
comment_like_s: true,
|
|
||||||
comment_hot_threshold: 10,
|
|
||||||
comment_city_s: true,
|
|
||||||
comment_city_type: 'province_city',
|
|
||||||
comment_user_url_s: false,
|
|
||||||
comment_author_tag: true,
|
comment_author_tag: true,
|
||||||
|
user_edit_comment: true,
|
||||||
|
comment_like_s: true,
|
||||||
|
comment_user_url_s: false,
|
||||||
comment_corderby: true,
|
comment_corderby: true,
|
||||||
comment_empty_text: '暂无评论,来做第一个评论的人吧~',
|
|
||||||
comment_title: '评论',
|
comment_title: '评论',
|
||||||
comment_submit_text: '提交评论',
|
comment_submit_text: '提交评论',
|
||||||
comment_text: '欢迎您留下宝贵的见解!',
|
comment_text: '欢迎您留下宝贵的见解!',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'display',
|
key: 'comment',
|
||||||
title: '评论展示',
|
title: '',
|
||||||
icon: 'fa fas fa-comment',
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'comment_order', label: '评论排序', type: 'radio', default: 'asc', options: [
|
{
|
||||||
{ label: '正序 (旧→新)', value: 'asc' },
|
key: 'comment_discussion_notice',
|
||||||
{ label: '倒序 (新→旧)', value: 'desc' },
|
label: '',
|
||||||
]},
|
type: 'content',
|
||||||
{ key: 'comment_per_page', label: '每页评论数', type: 'number', default: 20, min: 5, max: 100 },
|
ui_only: true,
|
||||||
{ key: 'comment_pagination_type', label: '分页方式', type: 'radio', default: 'numbered', options: [
|
dependsOn: { key: 'close_comments', value: false },
|
||||||
{ label: '翻页', value: 'numbered' },
|
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;"><i class="fa fas fa-fw fa-info-circle"></i> WordPress默认关闭评论翻页功能,如需启用请按此进行设置:<br/>1. 进入WP讨论设置,勾选 <code>分页显示评论</code><br/>2. 设置默认显示 <code>最前</code>一页<br/>3. 设置在每个页面顶部显示 <code>旧的</code>评论<br/>4. 根据需要设置每一页显示数量<br/>5. 根据需要设置评论嵌套(推荐开启并设置为3层)<br/>6. wp默认开启免登陆评论,如需关闭则勾选<code>需要登录后才能发表评论</code><br/><a href="options-discussion.php">【去设置】</a></p>',
|
||||||
{ label: '加载更多', value: 'load_more' },
|
},
|
||||||
{ label: '无限滚动', value: 'infinite' },
|
{ key: 'close_comments', label: '关闭文章评论功能', type: 'switch', default: false, description: '部分网站无需交互,或需备案审核,可在此关闭所有文章的评论功能。同时每一篇文章可单独关闭评论功能' },
|
||||||
]},
|
{
|
||||||
{ key: 'comment_paging_ajax_ias_s', label: '评论自动加载', type: 'switch', default: true, help: '评论无限滚动时自动触发加载' },
|
key: 'comment_box_hide',
|
||||||
{ key: 'comment_paging_ajax_ias_max', label: '评论最大加载页数', type: 'number', default: 3, min: 0, max: 10, help: '0 = 不限' },
|
label: '隐藏评论模块',
|
||||||
{ key: 'comment_nested_depth', label: '回复嵌套深度', type: 'number', default: 3, min: 1, max: 10 },
|
type: 'radio',
|
||||||
{ key: 'comment_avatar_size', label: '头像大小 (px)', type: 'number', default: 48, min: 24, max: 96 },
|
default: '',
|
||||||
],
|
dependsOn: { key: 'close_comments', value: false },
|
||||||
},
|
options: [
|
||||||
{
|
{ label: '不隐藏', value: '' },
|
||||||
key: 'permissions',
|
{ label: '登录后显示', value: 'signin' },
|
||||||
title: '评论设置',
|
{ label: '所有会员显示', value: 'vip' },
|
||||||
icon: 'fa fas fa-lock',
|
{ label: '二级会员显示', value: 'vip_2' },
|
||||||
fields: [
|
{ label: '认证用户显示', value: 'auth' },
|
||||||
{ key: 'comment_close_comments', label: '关闭评论', type: 'switch', default: false },
|
],
|
||||||
{ key: 'comment_edit_window', label: '作者自助编辑时限 (分钟)', type: 'number', default: 10, min: 0, max: 1440 },
|
description: '当用户满足条件时,文章评论功能及模块才会显示',
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'comment_paginate_type',
|
||||||
key: 'toolbar',
|
label: '评论列表翻页模式',
|
||||||
title: '评论工具栏',
|
type: 'radio',
|
||||||
icon: 'fa fas fa-wrench',
|
default: 'default',
|
||||||
fields: [
|
dependsOn: { key: 'close_comments', value: false },
|
||||||
{ key: 'comment_code', label: '代码插入', type: 'switch', default: true },
|
options: [
|
||||||
{ key: 'comment_img', label: '图片按钮', type: 'switch', default: true },
|
{ label: 'AJAX追加列表翻页', value: 'ajax_lists' },
|
||||||
{ key: 'comment_link', label: '链接按钮', type: 'switch', default: false },
|
{ label: '数字翻页按钮', value: 'default' },
|
||||||
{ key: 'comment_upload_img', label: '评论图片上传', type: 'switch', default: false },
|
],
|
||||||
],
|
},
|
||||||
},
|
{ key: 'comment_paging_ajax_ias_s', label: 'AJAX翻页自动加载', type: 'switch', default: true, dependsOn: { key: 'comment_paginate_type', value: 'ajax_lists' }, description: '页面滚动到列表尽头时,自动加载下一页' },
|
||||||
{
|
{ key: 'comment_paging_ajax_ias_max', label: '自动加载页数', type: 'number', default: 3, min: 0, max: 10, unit: '页', dependsOn: { all: [{ key: 'comment_paginate_type', value: 'ajax_lists' }, { key: 'comment_paging_ajax_ias_s', value: true }] }, description: 'AJAX翻页自动加载最多加载几页(为0则不限制,直到加载全部评论)' },
|
||||||
key: 'interaction',
|
{ key: 'comment_code', label: '允许插入代码', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false }, help: '为了防止恶意评论,建议在后台-设置-讨论:开启“用户必须登录后才能发表评论”' },
|
||||||
title: '互动功能',
|
{ key: 'comment_img', label: '允许插入图片', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
|
||||||
icon: 'fa fas fa-heart',
|
{ key: 'comment_upload_img', label: '允许上传图片', type: 'switch', default: false, dependsOn: { all: [{ key: 'close_comments', value: false }, { key: 'comment_img', value: true }] } },
|
||||||
fields: [
|
{ key: 'comment_link', label: '允许插入链接', type: 'switch', default: false },
|
||||||
{ key: 'comment_hot_threshold', label: '神评点赞阈值', type: 'number', default: 10, min: 1, max: 999, help: '点赞数达到该值后显示「神评」徽章', dependsOn: { key: 'comment_like_s', value: true } },
|
{ key: 'comment_author_tag', label: '显示“作者”标签', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
|
||||||
{ key: 'comment_like_s', label: '评论点赞', type: 'switch', default: true },
|
{ key: 'user_edit_comment', label: '允许用户编辑评论', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
|
||||||
{ key: 'comment_user_url_s', label: '匿名者网址', type: 'switch', default: false, help: '用户未登录评论时,允许输入用户博客地址' },
|
{ key: 'comment_like_s', label: '评论点赞功能', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
|
||||||
{ key: 'comment_city_s', label: '评论城市', type: 'switch', default: true },
|
{ key: 'comment_user_url_s', label: '匿名评论允许输入链接', type: 'switch', default: false, dependsOn: { key: 'close_comments', value: false }, description: '用户未登录评论时,允许输入用户博客地址' },
|
||||||
{ key: 'comment_city_type', label: '城市显示格式', type: 'radio', default: 'province_city', options: [
|
{ key: 'comment_corderby', label: '评论排序功能', type: 'switch', default: true, dependsOn: { all: [{ key: 'close_comments', value: false }, { key: 'comment_like_s', value: true }] }, description: '显示“最新、最热”排序按钮' },
|
||||||
{ label: '省份+城市', value: 'province_city' },
|
{ key: 'comment_title', label: '自定义评论标题', type: 'textarea', default: '评论', rows: 1, dependsOn: { key: 'close_comments', value: false } },
|
||||||
{ label: '仅省份', value: 'province' },
|
{ key: 'comment_submit_text', label: '自定义评论提交按钮文案', type: 'textarea', default: '提交评论', rows: 1, dependsOn: { key: 'close_comments', value: false } },
|
||||||
{ label: '国家+省份', value: 'country_province' },
|
{ key: 'comment_text', label: '自定义评论框占位符文案', type: 'input', default: '欢迎您留下宝贵的见解!', dependsOn: { key: 'close_comments', value: false } },
|
||||||
], dependsOn: { key: 'comment_city_s', value: true } },
|
|
||||||
{ key: 'comment_author_tag', label: '作者标签', type: 'switch', default: true },
|
|
||||||
{ key: 'comment_corderby', label: '评论排序切换', type: 'switch', default: true, help: '前台显示最新/最早/最热切换按钮' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'text',
|
|
||||||
title: '文案配置',
|
|
||||||
icon: 'fa fas fa-font',
|
|
||||||
fields: [
|
|
||||||
{ key: 'comment_empty_text', label: '空评论提示', type: 'input', default: '暂无评论,来做第一个评论的人吧~' },
|
|
||||||
{ key: 'comment_title', label: '评论区标题', type: 'input', default: '评论' },
|
|
||||||
{ key: 'comment_submit_text', label: '提交按钮文案', type: 'input', default: '提交评论' },
|
|
||||||
{ key: 'comment_text', label: '评论框提示', type: 'input', default: '欢迎您留下宝贵的见解!' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,19 +2,121 @@ export const featureSchema = {
|
|||||||
key: 'post_list.feature',
|
key: 'post_list.feature',
|
||||||
title: '文章功能',
|
title: '文章功能',
|
||||||
icon: 'fa fas fa-magic',
|
icon: 'fa fas fa-magic',
|
||||||
description: '配置文章页的互动功能开关。',
|
description: '配置文章目录树、图片灯箱、点赞与新建文章的预置参数。',
|
||||||
defaults: {
|
defaults: {
|
||||||
feature_pdf_export: false,
|
article_nav: true,
|
||||||
feature_print: true,
|
article_nav_mobile_nav_s: true,
|
||||||
|
imagelightbox: true,
|
||||||
|
imagelightbox_type: 'group',
|
||||||
|
imagelightbox_thumbs_s: ['pc_s', 'm_s'],
|
||||||
|
imagelightbox_play_s: ['pc_s', 'm_s'],
|
||||||
|
imagelightbox_zoom_s: ['pc_s'],
|
||||||
|
imagelightbox_full_s: ['pc_s'],
|
||||||
|
imagelightbox_down_s: ['pc_s'],
|
||||||
|
post_like_s: true,
|
||||||
|
post_default_mate: { views: { min: 20, max: 50 }, like: { min: 5, max: 15 } },
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'tools',
|
key: 'feature',
|
||||||
title: '工具功能',
|
title: '',
|
||||||
icon: 'fa fas fa-wrench',
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'feature_print', label: '打印文章', type: 'switch', default: true },
|
{ key: 'article_nav', label: '文章目录树', type: 'switch', default: true, description: '默认开关,每篇文章可单独设置。开启后请自行添加文章目录树模块到侧边栏(文章内标题超过3个才会显示)' },
|
||||||
{ key: 'feature_pdf_export', label: 'PDF 导出', type: 'switch', default: false, help: '实验性功能' },
|
{
|
||||||
|
key: 'article_nav_tutorial',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
dependsOn: { key: 'article_nav', value: true },
|
||||||
|
content: '<p style="margin:0 0 12px;font-size:12px;"><a href="https://www.zibll.com/1717.html" target="_blank">【查看官网教程】</a></p>',
|
||||||
|
},
|
||||||
|
{ key: 'article_nav_mobile_nav_s', label: '在移动端弹出菜单内显示', type: 'switch', default: true, dependsOn: { key: 'article_nav', value: true }, description: '如需显示在侧边栏等其它位置,请使用小工具模块添加' },
|
||||||
|
{ key: 'imagelightbox', label: '图片灯箱', type: 'switch', default: true, description: '点击图片查看原图功能,共两种模式' },
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_tutorial',
|
||||||
|
label: '',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
dependsOn: { key: 'imagelightbox', value: true },
|
||||||
|
content: '<p style="margin:0 0 12px;font-size:12px;"><a href="https://www.zibll.com/683.html" target="_blank">【查看官网教程】</a></p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_type',
|
||||||
|
label: '图片灯箱模式',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'group',
|
||||||
|
dependsOn: { key: 'imagelightbox', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: '组合模式(将文章的全部图片组合成一组灯箱)', value: 'group' },
|
||||||
|
{ label: '单张模式(点击某张图片仅显示该图片)', value: 'alone' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_thumbs_s',
|
||||||
|
label: '缩略图导航',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s', 'm_s'],
|
||||||
|
dependsOn: { all: [{ key: 'imagelightbox', value: true }, { key: 'imagelightbox_type', value: 'group' }] },
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_play_s',
|
||||||
|
label: '播放按钮',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s', 'm_s'],
|
||||||
|
dependsOn: { all: [{ key: 'imagelightbox', value: true }, { key: 'imagelightbox_type', value: 'group' }] },
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_zoom_s',
|
||||||
|
label: '缩放按钮',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s'],
|
||||||
|
dependsOn: { key: 'imagelightbox', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_full_s',
|
||||||
|
label: '全屏按钮',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s'],
|
||||||
|
dependsOn: { key: 'imagelightbox', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imagelightbox_down_s',
|
||||||
|
label: '下载按钮',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: ['pc_s'],
|
||||||
|
dependsOn: { key: 'imagelightbox', value: true },
|
||||||
|
options: [
|
||||||
|
{ label: 'PC端开启', value: 'pc_s' },
|
||||||
|
{ label: '移动端开启', value: 'm_s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'post_like_s', label: '文章点赞', type: 'switch', default: true },
|
||||||
|
{
|
||||||
|
key: 'post_default_mate',
|
||||||
|
label: '文章预置参数',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { views: { min: 20, max: 50 }, like: { min: 5, max: 15 } },
|
||||||
|
fields: [
|
||||||
|
{ key: 'views', label: '阅读量', type: 'between_number', default: { min: 20, max: 50 } },
|
||||||
|
{ key: 'like', label: '点赞数', type: 'between_number', default: { min: 5, max: 15 }, description: '新建文章时系统会根据此处设置的区间获取随机值填入对应项' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,101 +1,128 @@
|
|||||||
export const filterSchema = {
|
export const filterSchema = {
|
||||||
key: 'post_list.filter',
|
key: 'post_list.filter',
|
||||||
title: '高级筛选',
|
title: '高级筛选',
|
||||||
icon: 'fa fas fa-filter',
|
icon: 'fa fas fa-align-left',
|
||||||
description: '为文章列表配置筛选栏、排序方式和分类筛选功能。',
|
description: '通过自定义筛选类型实现更精细化的内容筛选,可按分类绑定不同的筛选项目。',
|
||||||
defaults: {
|
defaults: {
|
||||||
filter_enabled: false,
|
custom_filter_show: [],
|
||||||
filter_categories: true,
|
|
||||||
filter_tags: false,
|
|
||||||
filter_sort: true,
|
|
||||||
filter_ajax_enabled: true,
|
|
||||||
filter_cat_show_cat: true,
|
|
||||||
filter_cat_show_tag: false,
|
|
||||||
filter_tag_show_cat: true,
|
|
||||||
filter_tag_show_tag: false,
|
|
||||||
custom_filter_show: ['cat', 'tag'],
|
|
||||||
custom_filter: [],
|
custom_filter: [],
|
||||||
cat_custom_filter: [],
|
cat_custom_filter: [],
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'advanced_filter',
|
||||||
title: '筛选基础',
|
title: '高级筛选',
|
||||||
icon: 'fa fas fa-filter',
|
icon: 'fa fas fa-align-left',
|
||||||
fields: [
|
|
||||||
{ key: 'filter_enabled', label: '启用筛选栏', type: 'switch', default: false },
|
|
||||||
{ key: 'custom_filter_show', label: '筛选显示页面', type: 'checkbox', default: ['cat', 'tag'], options: [{ label: '分类页', value: 'cat' }, { label: '标签页', value: 'tag' }], help: '选择在哪些页面显示筛选栏', dependsOn: { key: 'filter_enabled', value: true } },
|
|
||||||
{ key: 'filter_categories', label: '分类筛选', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } },
|
|
||||||
{ key: 'filter_tags', label: '标签筛选', type: 'switch', default: false, dependsOn: { key: 'filter_enabled', value: true } },
|
|
||||||
{ key: 'filter_sort', label: '排序选项', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } },
|
|
||||||
{ key: 'filter_ajax_enabled', label: 'AJAX 无刷新筛选', type: 'switch', default: true, help: '启用后分类/标签/排序切换将通过 AJAX 加载,无需刷新页面', dependsOn: { key: 'filter_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ajax',
|
|
||||||
title: 'AJAX 筛选维度',
|
|
||||||
icon: 'fa fas fa-clone',
|
|
||||||
fields: [
|
|
||||||
{ key: 'filter_cat_show_cat', label: '分类页:显示分类筛选', type: 'switch', default: true },
|
|
||||||
{ key: 'filter_cat_show_tag', label: '分类页:显示标签筛选', type: 'switch', default: false },
|
|
||||||
{ key: 'filter_tag_show_cat', label: '标签页:显示分类筛选', type: 'switch', default: true },
|
|
||||||
{ key: 'filter_tag_show_tag', label: '标签页:显示标签筛选', type: 'switch', default: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'custom_filter',
|
|
||||||
title: '自定义筛选维度',
|
|
||||||
icon: 'fa fas fa-sliders-h',
|
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'filter_intro',
|
||||||
|
type: 'content',
|
||||||
|
ui_only: true,
|
||||||
|
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.7;"><b>文章高级筛选功能:</b>通过此功能可实现更加复杂、精细化的内容筛选<br/>添加筛选类型和内容值之后,编写文章时候,可以进行选择,且在前台也可以实现精准筛选<br/>例如影视网站:[类型]有[剧情/喜剧/悬疑/惊悚/犯罪],[地区]有[大陆/美国/日韩/港台/印度],[年份]有[2022/2021/2020/10年代/00年代]等<br/><span style="color:#d48806;">注意事项:添加类型key时候,只能使用英文加下划线,不能有空格,且尽量复杂一点(不能为tag,category等系统已存在的类型),避免与其他mate_key重复!类型名称不能为分类、标签等系统已存在的类型</span><br/><a href="https://www.zibll.com/15886.html" target="_blank">查看官网教程</a></p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'custom_filter_show',
|
||||||
|
label: '高级筛选默认显示',
|
||||||
|
type: 'checkbox',
|
||||||
|
default: [],
|
||||||
|
options: [
|
||||||
|
{ label: '分类页面显示', value: 'cat' },
|
||||||
|
{ label: '标签页面显示', value: 'tag' },
|
||||||
|
],
|
||||||
|
description: '勾选后将会在对应页面默认显示全部筛选,您可以在下方为单独的分类添加自定义筛选',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'custom_filter',
|
key: 'custom_filter',
|
||||||
label: '筛选维度定义',
|
label: '自定义高级筛选明细',
|
||||||
type: 'repeater',
|
type: 'repeater',
|
||||||
default: [],
|
default: [],
|
||||||
addText: '添加筛选维度',
|
addText: '添加筛选类别',
|
||||||
help: '每项定义一个筛选维度(如"类型""地区""年份"),及其可选值列表',
|
description: '添加筛选类型及明细',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', label: '维度名称', type: 'input', default: '', placeholder: '如:类型' },
|
{ key: 'name', label: '类型名称', type: 'input', default: '' },
|
||||||
{ key: 'key', label: '维度标识', type: 'input', default: '', placeholder: '如:type(英文下划线)' },
|
{ key: 'key', label: '类型key', type: 'input', default: '', description: '必填,只能使用英文加下划线,不能有空格!尽量复杂一点,且配置唯一前缀,以避免与其他mate_key重复,例如(axb_type,axb_year)' },
|
||||||
{
|
{
|
||||||
key: 'vals',
|
key: 'vals',
|
||||||
label: '可选值',
|
label: '可选择的值',
|
||||||
type: 'repeater',
|
type: 'repeater',
|
||||||
default: [],
|
default: [],
|
||||||
addText: '添加可选值',
|
addText: '添加值',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'key', label: '标识', type: 'input', default: '', placeholder: '如:action' },
|
{ key: 'key', label: '值', type: 'input', default: '' },
|
||||||
{ key: 'name', label: '显示名', type: 'input', default: '', placeholder: '如:动作' },
|
{ key: 'name', label: '显示名称', type: 'input', default: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cat_custom_filter',
|
|
||||||
title: '按分类绑定筛选',
|
|
||||||
icon: 'fa fas fa-object-group',
|
|
||||||
fields: [
|
|
||||||
{
|
{
|
||||||
key: 'cat_custom_filter',
|
key: 'cat_custom_filter',
|
||||||
label: '分类筛选绑定',
|
label: '自定义分类的高级筛选',
|
||||||
type: 'repeater',
|
type: 'repeater',
|
||||||
default: [],
|
default: [],
|
||||||
addText: '添加绑定规则',
|
addText: '添加自定义分类筛选',
|
||||||
help: '为指定分类绑定不同的筛选维度组合,实现分类级别的差异化筛选',
|
description: '在此处为不同的分类添加不同的筛选项目,实现更加精细化的筛选。同时你也可以在页面显示/分类页面设置分类的默认筛选方式',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [] },
|
{ key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], description: '支持添加多个分类' },
|
||||||
{ key: 'cat_s', label: '显示分类筛选', type: 'switch', default: false },
|
{ key: 'cat_s', label: '分类筛选', type: 'switch', default: false, description: '在所选分类页显示的分类筛选列表' },
|
||||||
{ key: 'tag_s', label: '显示标签筛选', type: 'switch', default: false },
|
{
|
||||||
{ key: 'orderby_s', label: '显示排序按钮', type: 'switch', default: true },
|
key: 'cat_lists',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { lists: [], dropdown: false },
|
||||||
|
dependsOn: { key: 'cat_s', value: true },
|
||||||
|
fields: [
|
||||||
|
{ key: 'lists', label: '按钮列表', type: 'async_select', source: 'categories', multiple: true, sortable: true, default: [], description: '选择并排序需要显示的按钮,建议选择同级内容,会自动获取二级三级内容。所选按钮内没有文章则不会显示' },
|
||||||
|
{ key: 'dropdown', label: '下拉列表', type: 'switch', default: false, description: '用下拉框显示更多内容' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'tag_s', label: '标签筛选', type: 'switch', default: false, description: '在所选分类页显示的标签筛选列表' },
|
||||||
|
{
|
||||||
|
key: 'tag_lists',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { lists: [], dropdown: false },
|
||||||
|
dependsOn: { key: 'tag_s', value: true },
|
||||||
|
fields: [
|
||||||
|
{ key: 'lists', label: '按钮列表', type: 'async_select', source: 'tags', multiple: true, sortable: true, default: [], description: '选择并排序需要显示的按钮' },
|
||||||
|
{ key: 'dropdown', label: '下拉列表', type: 'switch', default: false, description: '用下拉框显示更多内容' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'orderby_s', label: '排序方式', type: 'switch', default: true, description: '在所选分类页显示排序方式按钮' },
|
||||||
|
{
|
||||||
|
key: 'orderby_lists',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { lists: ['modified', 'views', 'like', 'comment_count'], dropdown: false },
|
||||||
|
dependsOn: { key: 'orderby_s', value: true },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'lists',
|
||||||
|
label: '显示排序方式',
|
||||||
|
type: 'select',
|
||||||
|
multiple: true,
|
||||||
|
sortable: true,
|
||||||
|
default: ['modified', 'views', 'like', 'comment_count'],
|
||||||
|
options: [
|
||||||
|
{ label: '更新', value: 'modified' },
|
||||||
|
{ label: '发布', value: 'date' },
|
||||||
|
{ label: '浏览', value: 'views' },
|
||||||
|
{ label: '点赞', value: 'like' },
|
||||||
|
{ label: '评论', value: 'comment_count' },
|
||||||
|
{ label: '收藏', value: 'favorite' },
|
||||||
|
{ label: '随机', value: 'rand' },
|
||||||
|
],
|
||||||
|
description: '选择需要的排序方式按钮',
|
||||||
|
},
|
||||||
|
{ key: 'dropdown', label: '更多排序方式', type: 'switch', default: false, description: '用下拉框显示全部排序方式' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'custom_filters',
|
key: 'custom_filters',
|
||||||
label: '启用的自定义维度',
|
label: '自定义高级筛选',
|
||||||
type: 'input',
|
type: 'checkbox',
|
||||||
default: '',
|
default: [],
|
||||||
placeholder: '逗号分隔维度标识,如:type,region,year',
|
options: 'custom_filter',
|
||||||
help: '留空表示使用全部已定义的筛选维度',
|
description: '在所选分类页显示的自定义高级筛选项目。如果此处没有选项,或者您修改了上方的文章高级筛选明细,请先保存主题配置并刷新页面后再设置此处',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { pageSchema } from './page.js'
|
|||||||
import { filterSchema } from './filter.js'
|
import { filterSchema } from './filter.js'
|
||||||
import { featureSchema } from './feature.js'
|
import { featureSchema } from './feature.js'
|
||||||
import { paySchema } from './pay.js'
|
import { paySchema } from './pay.js'
|
||||||
import { shareSchema } from './share.js'
|
|
||||||
import { commentSchema } from './comment.js'
|
import { commentSchema } from './comment.js'
|
||||||
import { submitSchema } from './submit.js'
|
import { submitSchema } from './submit.js'
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ export const postListSchemas = {
|
|||||||
filter: filterSchema,
|
filter: filterSchema,
|
||||||
feature: featureSchema,
|
feature: featureSchema,
|
||||||
pay: paySchema,
|
pay: paySchema,
|
||||||
share: shareSchema,
|
|
||||||
comment: commentSchema,
|
comment: commentSchema,
|
||||||
submit: submitSchema,
|
submit: submitSchema,
|
||||||
}
|
}
|
||||||
@@ -1,180 +1,192 @@
|
|||||||
export const pageSchema = {
|
export const pageSchema = {
|
||||||
key: 'post_list.page',
|
key: 'post_list.page',
|
||||||
title: '文章页面',
|
title: '文章页面',
|
||||||
icon: 'fa fas fa-file-alt',
|
icon: 'fa fas fa-bookmark',
|
||||||
description: '配置单篇文章页面的布局、图片灯箱、版权区和互动功能。',
|
description: '配置文章页面封面、面包屑导航、版权声明、插入内容与相关文章版块。',
|
||||||
defaults: {
|
defaults: {
|
||||||
post_reading_progress: true,
|
article_image_cover: true,
|
||||||
|
article_image_cover_option: { scale: 35 },
|
||||||
|
article_slide_cover: true,
|
||||||
|
article_slide_cover_option: {
|
||||||
|
direction: 'horizontal',
|
||||||
|
loop: true,
|
||||||
|
button: false,
|
||||||
|
pagination: true,
|
||||||
|
effect: 'slide',
|
||||||
|
scale_height: true,
|
||||||
|
scale: 35,
|
||||||
|
auto_height: false,
|
||||||
|
max_height: 500,
|
||||||
|
min_height: 180,
|
||||||
|
pc_height: 380,
|
||||||
|
m_height: 180,
|
||||||
|
spacebetween: 15,
|
||||||
|
speed: 0,
|
||||||
|
autoplay: true,
|
||||||
|
interval: 4,
|
||||||
|
},
|
||||||
|
article_video_cover: true,
|
||||||
|
article_video_cover_option: { scale: 0 },
|
||||||
|
post_single_hide_time_s: false,
|
||||||
|
post_p_indent_s: false,
|
||||||
breadcrumbs_single_s: true,
|
breadcrumbs_single_s: true,
|
||||||
breadcrumbs_home_text: '首页',
|
breadcrumbs_home_text: true,
|
||||||
post_prevnext_s: true,
|
post_prevnext_s: true,
|
||||||
post_authordesc_s: true,
|
post_authordesc_s: true,
|
||||||
post_single_hide_time_s: false,
|
article_maxheight_kg: false,
|
||||||
post_lightbox: true,
|
article_maxheight: 1000,
|
||||||
post_lightbox_thumbs: true,
|
yiyan_single_content_header: false,
|
||||||
post_lightbox_zoom: true,
|
yiyan_single_content_footer: false,
|
||||||
post_lightbox_full: true,
|
yiyan_single_box: false,
|
||||||
post_lightbox_down: false,
|
post_copyright_s: true,
|
||||||
post_lightbox_play: false,
|
post_copyright: '文章版权归作者所有,未经允许请勿转载。',
|
||||||
|
post_button_toptext: '喜欢就支持一下吧',
|
||||||
|
post_front_content: '',
|
||||||
|
post_after_content: '',
|
||||||
post_related_s: true,
|
post_related_s: true,
|
||||||
post_related_type: 'img',
|
post_related_type: 'img',
|
||||||
related_title: '相关推荐',
|
related_title: '相关推荐',
|
||||||
post_related_n: 6,
|
post_related_n: 6,
|
||||||
post_related_orderby: 'views',
|
post_related_orderby: 'views',
|
||||||
breadcrumbs_single_text: true,
|
|
||||||
post_copyright_enabled: true,
|
|
||||||
post_copyright_text: '',
|
|
||||||
share_s: true,
|
|
||||||
post_like_enabled: true,
|
|
||||||
post_favorite_enabled: true,
|
|
||||||
post_front_content: '',
|
|
||||||
post_after_content: '',
|
|
||||||
post_yiyan_header: false,
|
|
||||||
post_yiyan_footer: false,
|
|
||||||
post_yiyan_box: false,
|
|
||||||
post_default_views_min: 20,
|
|
||||||
post_default_views_max: 50,
|
|
||||||
post_default_likes_min: 5,
|
|
||||||
post_default_likes_max: 15,
|
|
||||||
post_article_image_cover: true,
|
|
||||||
post_article_image_cover_scale: 35,
|
|
||||||
post_article_slide_cover: true,
|
|
||||||
post_article_video_cover: true,
|
|
||||||
post_article_video_cover_scale: 56,
|
|
||||||
item_heading_bold: false,
|
|
||||||
post_button_toptext: '喜欢就支持一下吧',
|
|
||||||
post_rewards_s: false,
|
|
||||||
post_rewards_text: '赞赏',
|
|
||||||
post_author_card_limit: 6,
|
|
||||||
post_author_card_orderby: 'views',
|
|
||||||
post_author_card_style: 'card',
|
|
||||||
article_maxheight_kg: false,
|
|
||||||
article_maxheight: 1000,
|
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'navigation',
|
key: 'post_page',
|
||||||
title: '文章导航',
|
title: '文章页面',
|
||||||
icon: 'fa fas fa-compass',
|
icon: 'fa fas fa-bookmark',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'post_reading_progress', label: '阅读进度条', type: 'switch', default: true },
|
{ key: 'article_image_cover', label: '图片封面', type: 'switch', default: true, description: '允许设置文章页顶部显示封面图' },
|
||||||
|
{
|
||||||
|
key: 'article_image_cover_option',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { scale: 35 },
|
||||||
|
dependsOn: { key: 'article_image_cover', value: true },
|
||||||
|
description: '图片封面配置',
|
||||||
|
fields: [
|
||||||
|
{ key: 'scale', label: '图片长宽比例', type: 'number', default: 35, min: 20, max: 200, step: 5, unit: '%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'article_slide_cover', label: '幻灯片封面', type: 'switch', default: true, description: '允许设置幻灯片为文章封面' },
|
||||||
|
{
|
||||||
|
key: 'article_slide_cover_option',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: {
|
||||||
|
direction: 'horizontal',
|
||||||
|
loop: true,
|
||||||
|
button: false,
|
||||||
|
pagination: true,
|
||||||
|
effect: 'slide',
|
||||||
|
scale_height: true,
|
||||||
|
scale: 35,
|
||||||
|
auto_height: false,
|
||||||
|
max_height: 500,
|
||||||
|
min_height: 180,
|
||||||
|
pc_height: 380,
|
||||||
|
m_height: 180,
|
||||||
|
spacebetween: 15,
|
||||||
|
speed: 0,
|
||||||
|
autoplay: true,
|
||||||
|
interval: 4,
|
||||||
|
},
|
||||||
|
dependsOn: { key: 'article_slide_cover', value: true },
|
||||||
|
fields: [
|
||||||
|
{ key: 'direction', label: '幻灯片方向', type: 'radio', default: 'horizontal', options: [{ label: '左右切换', value: 'horizontal' }, { label: '上下切换', value: 'vertical' }] },
|
||||||
|
{ key: 'loop', label: '循环切换', type: 'switch', default: true },
|
||||||
|
{ key: 'button', label: '显示翻页按钮', type: 'switch', default: false },
|
||||||
|
{ key: 'pagination', label: '显示指示器', type: 'switch', default: true },
|
||||||
|
{
|
||||||
|
key: 'effect',
|
||||||
|
label: '切换动画',
|
||||||
|
type: 'select',
|
||||||
|
default: 'slide',
|
||||||
|
options: [
|
||||||
|
{ label: '滑动', value: 'slide' },
|
||||||
|
{ label: '淡出淡入', value: 'fade' },
|
||||||
|
{ label: '3D方块', value: 'cube' },
|
||||||
|
{ label: '3D滑入', value: 'coverflow' },
|
||||||
|
{ label: '3D翻转', value: 'flip' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'scale_height', label: '按比例自动高度', type: 'switch', default: true, dependsOn: { key: 'direction', not: 'vertical' } },
|
||||||
|
{ key: 'scale', label: '长宽比例', type: 'number', default: 35, min: 10, max: 300, step: 5, unit: '%', dependsOn: { all: [{ key: 'direction', not: 'vertical' }, { key: 'scale_height', value: true }] } },
|
||||||
|
{ key: 'auto_height', label: '自动高度', type: 'switch', default: false, dependsOn: { all: [{ key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
|
||||||
|
{ key: 'max_height', label: '最大高度', type: 'number', default: 500, min: 120, max: 800, step: 20, unit: 'PX', dependsOn: { all: [{ key: 'auto_height', value: true }, { key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
|
||||||
|
{ key: 'min_height', label: '最小高度', type: 'number', default: 180, min: 100, max: 500, step: 20, unit: 'PX', dependsOn: { all: [{ key: 'auto_height', value: true }, { key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
|
||||||
|
{ key: 'pc_height', label: '电脑端高度', type: 'number', default: 380, min: 120, max: 800, step: 20, unit: 'PX' },
|
||||||
|
{ key: 'm_height', label: '移动端高度', type: 'number', default: 180, min: 100, max: 500, step: 20, unit: 'PX' },
|
||||||
|
{ key: 'spacebetween', label: '幻灯片间距', type: 'number', default: 15, min: 0, max: 500, step: 5, unit: 'PX' },
|
||||||
|
{ key: 'speed', label: '切换速度', type: 'number', default: 0, min: 0, max: 3000, step: 100, unit: '毫秒', description: '切换过程的时间(越小越快),设置为0则为自动模式' },
|
||||||
|
{ key: 'autoplay', label: '自动播放', type: 'switch', default: true },
|
||||||
|
{ key: 'interval', label: '停顿时间', type: 'number', default: 4, min: 0, max: 20, step: 1, unit: '秒', dependsOn: { key: 'autoplay', value: true }, description: '自动切换的时间间隔(越小越快)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'article_video_cover', label: '视频封面', type: 'switch', default: true, description: '允许设置视频为文章封面' },
|
||||||
|
{
|
||||||
|
key: 'article_video_cover_option',
|
||||||
|
label: ' ',
|
||||||
|
type: 'fieldset',
|
||||||
|
default: { scale: 0 },
|
||||||
|
dependsOn: { key: 'article_video_cover', value: true },
|
||||||
|
description: '视频封面配置 <a href="https://www.zibll.com/1900.html" target="_blank">【查看官方封面配置教程】</a>',
|
||||||
|
fields: [
|
||||||
|
{ key: 'scale', label: '固定长宽比例', type: 'number', default: 0, min: 0, max: 200, step: 5, unit: '%', description: '为0则不固定长宽比例' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'post_single_hide_time_s', label: '隐藏时间', type: 'switch', default: false, description: '隐藏文章的更新及发布时间显示' },
|
||||||
|
{ key: 'post_p_indent_s', label: '内容段落缩进', type: 'switch', default: false, description: '开启后文章内容每一个段落首行将向右偏移2个文字距离' },
|
||||||
{ key: 'breadcrumbs_single_s', label: '面包屑导航', type: 'switch', default: true },
|
{ key: 'breadcrumbs_single_s', label: '面包屑导航', type: 'switch', default: true },
|
||||||
{ key: 'breadcrumbs_home_text', label: '面包屑首页文字', type: 'input', default: '首页', dependsOn: { key: 'breadcrumbs_single_s', value: true } },
|
{ key: 'breadcrumbs_home_text', label: '面包屑导航用“首页”替代网站名称', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true } },
|
||||||
{ key: 'breadcrumbs_single_text', label: '末项显示「正文」', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true }, help: '关闭后末项显示文章标题' },
|
{ key: 'post_prevnext_s', label: '上一页、下一页版块', type: 'switch', default: true },
|
||||||
{ key: 'post_prevnext_s', label: '上一篇/下一篇', type: 'switch', default: true },
|
{ key: 'post_authordesc_s', label: '作者信息版块', type: 'switch', default: true },
|
||||||
{ key: 'post_authordesc_s', label: '作者简介', type: 'switch', default: true },
|
{ key: 'article_maxheight_kg', label: '内容高度限制', type: 'switch', default: false, description: '此处为全局设置,每篇文章可单独设置', help: '全局开关' },
|
||||||
{ key: 'post_author_card_limit', label: '作者卡文章数', type: 'number', default: 6, min: 0, max: 12, dependsOn: { key: 'post_authordesc_s', value: true } },
|
{ key: 'article_maxheight', label: '限制的最大高度', type: 'slider', default: 1000, min: 600, max: 3000, step: 100, unit: 'px', dependsOn: { key: 'article_maxheight_kg', value: true }, description: '开启后如果文章高度超过设定值则会显示展开阅读全文的按钮。每篇文章可单独开启此功能' },
|
||||||
{ key: 'post_author_card_orderby', label: '作者卡文章排序', type: 'select', default: 'views', options: [
|
{
|
||||||
{ label: '浏览量', value: 'views' },
|
key: 'yiyan_intro',
|
||||||
{ label: '发布时间', value: 'date' },
|
type: 'content',
|
||||||
{ label: '更新时间', value: 'modified' },
|
ui_only: true,
|
||||||
{ label: '评论数', value: 'comment_count' },
|
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.7;"><b>精彩一言功能:</b>随机显示中英文的文案,在此支持一言内容插入到文章页位置,同时可以使用小工具模块调用。<br/>主题默认调用的文件为:<code>themes/aksrt/yiyan/qv-yiyan.txt</code><br/>您可以将以上文件复制到 <code>wp-content</code> 文件夹,并重命名为 <code>yiyan.txt</code>,再进行修改或添加内容,则今后更新主题就无需二次修改</p>',
|
||||||
], dependsOn: { key: 'post_authordesc_s', value: true } },
|
},
|
||||||
{ key: 'post_author_card_style', label: '作者卡文章样式', type: 'radio', default: 'card', options: [
|
{ key: 'yiyan_single_content_header', label: '文章内容头部显示一言', type: 'switch', default: false },
|
||||||
{ label: '卡片', value: 'card' },
|
{ key: 'yiyan_single_content_footer', label: '文章内容尾部显示一言', type: 'switch', default: false },
|
||||||
{ label: '列表', value: 'list' },
|
{ key: 'yiyan_single_box', label: '文章页面下方独立一言版块', type: 'switch', default: false },
|
||||||
], dependsOn: { key: 'post_authordesc_s', value: true } },
|
{ key: 'post_copyright_s', label: '版权声明', type: 'switch', default: true },
|
||||||
{ key: 'post_single_hide_time_s', label: '隐藏文章时间', type: 'switch', default: false },
|
{ key: 'post_copyright', label: '版权提示内容', type: 'textarea', default: '文章版权归作者所有,未经允许请勿转载。', rows: 2, dependsOn: { key: 'post_copyright_s', value: true }, description: '支持HTML代码,请注意代码规范及标签闭合' },
|
||||||
],
|
{ key: 'post_button_toptext', label: '文章页脚文案', type: 'input', default: '喜欢就支持一下吧', description: '文章底部打赏、分享按钮上面的文字' },
|
||||||
},
|
{ key: 'post_front_content', label: '在文章内容前-插入内容', type: 'textarea', default: '', rows: 3 },
|
||||||
{
|
{ key: 'post_after_content', label: '在文章内容后-插入内容', type: 'textarea', default: '', rows: 3, description: '在每篇文章顶部和尾部插入内容,可以插入广告或者文章说明等内容。支持HTML代码,请注意代码规范及标签闭合' },
|
||||||
key: 'cover',
|
{ key: 'post_related_s', label: '相关文章版块', type: 'switch', default: true },
|
||||||
title: '封面展示',
|
{
|
||||||
icon: 'fa fas fa-image',
|
key: 'post_related_type',
|
||||||
fields: [
|
label: '显示样式',
|
||||||
{ key: 'post_article_image_cover', label: '图片封面', type: 'switch', default: true, help: '文章有特色图时自动展示封面' },
|
type: 'image_select',
|
||||||
{ key: 'post_article_image_cover_scale', label: '封面高度比例(%)', type: 'number', default: 35, min: 10, max: 100, dependsOn: { key: 'post_article_image_cover', value: true } },
|
default: 'img',
|
||||||
{ key: 'post_article_slide_cover', label: '幻灯片封面', type: 'switch', default: true, help: '文章有图集时以轮播形式展示封面' },
|
dependsOn: { key: 'post_related_s', value: true },
|
||||||
{ key: 'post_article_video_cover', label: '视频封面', type: 'switch', default: true, help: '文章有视频时以视频播放器作为封面' },
|
options: [
|
||||||
{ key: 'post_article_video_cover_scale', label: '视频封面高度比例(%)', type: 'number', default: 56, min: 20, max: 100, dependsOn: { key: 'post_article_video_cover', value: true } },
|
{ label: '图文卡片', value: 'img', icon: 'fa fas fa-image' },
|
||||||
],
|
{ label: '列表', value: 'list', icon: 'fa fas fa-list' },
|
||||||
},
|
{ label: '纯文字', value: 'text', icon: 'fa fas fa-align-left' },
|
||||||
{
|
],
|
||||||
key: 'media',
|
},
|
||||||
title: '媒体功能',
|
{ key: 'related_title', label: '版块标题', type: 'input', default: '相关推荐', dependsOn: { key: 'post_related_s', value: true } },
|
||||||
icon: 'fa fas fa-image',
|
{ key: 'post_related_n', label: '显示数量', type: 'number', default: 6, min: 4, max: 12, step: 2, unit: '篇', dependsOn: { key: 'post_related_s', value: true } },
|
||||||
fields: [
|
{
|
||||||
{ key: 'post_lightbox', label: '图片灯箱', type: 'switch', default: true, help: '点击图片放大查看' },
|
key: 'post_related_orderby',
|
||||||
{ key: 'post_lightbox_thumbs', label: '灯箱缩略图', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱底部显示缩略图导航' },
|
label: '排序方式',
|
||||||
{ key: 'post_lightbox_zoom', label: '灯箱缩放', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示缩放按钮' },
|
type: 'select',
|
||||||
{ key: 'post_lightbox_full', label: '灯箱全屏', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示全屏按钮' },
|
default: 'views',
|
||||||
{ key: 'post_lightbox_down', label: '灯箱下载', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示下载按钮' },
|
dependsOn: { key: 'post_related_s', value: true },
|
||||||
{ key: 'post_lightbox_play', label: '灯箱播放', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示播放按钮(自动轮播)' },
|
options: [
|
||||||
],
|
{ label: '更新时间', value: 'modified' },
|
||||||
},
|
{ label: '发布时间', value: 'date' },
|
||||||
{
|
{ label: '浏览量', value: 'views' },
|
||||||
key: 'related',
|
{ label: '点赞量', value: 'like' },
|
||||||
title: '相关推荐',
|
{ label: '评论量', value: 'comment_count' },
|
||||||
icon: 'fa fas fa-random',
|
{ label: '收藏数量', value: 'favorite' },
|
||||||
fields: [
|
{ label: '随机', value: 'rand' },
|
||||||
{ key: 'post_related_s', label: '显示相关推荐', type: 'switch', default: true },
|
],
|
||||||
{ key: 'post_related_type', label: '展示样式', type: 'radio', default: 'img', options: [
|
},
|
||||||
{ label: '图文卡片', value: 'img' },
|
|
||||||
{ label: '列表', value: 'list' },
|
|
||||||
{ label: '纯文字', value: 'text' },
|
|
||||||
], dependsOn: { key: 'post_related_s', value: true }, help: '对齐 legacy 相关文章三种样式' },
|
|
||||||
{ key: 'related_title', label: '推荐标题', type: 'input', default: '相关推荐', dependsOn: { key: 'post_related_s', value: true } },
|
|
||||||
{ key: 'post_related_n', label: '推荐数量', type: 'number', default: 6, min: 4, max: 12, step: 2, dependsOn: { key: 'post_related_s', value: true } },
|
|
||||||
{ key: 'post_related_orderby', label: '推荐排序', type: 'select', default: 'views', options: [
|
|
||||||
{ label: '更新时间', value: 'modified' },
|
|
||||||
{ label: '发布时间', value: 'date' },
|
|
||||||
{ label: '浏览量', value: 'views' },
|
|
||||||
{ label: '点赞量', value: 'like' },
|
|
||||||
{ label: '评论量', value: 'comment_count' },
|
|
||||||
{ label: '收藏数量', value: 'favorite' },
|
|
||||||
{ label: '随机', value: 'rand' },
|
|
||||||
], dependsOn: { key: 'post_related_s', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'interactions',
|
|
||||||
title: '互动功能',
|
|
||||||
icon: 'fa fas fa-heart',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_like_enabled', label: '点赞', type: 'switch', default: true },
|
|
||||||
{ key: 'post_favorite_enabled', label: '收藏', type: 'switch', default: true },
|
|
||||||
{ key: 'share_s', label: '分享按钮', type: 'switch', default: true },
|
|
||||||
{ key: 'post_copyright_enabled', label: '版权信息', type: 'switch', default: true },
|
|
||||||
{ key: 'post_copyright_text', label: '版权默认文案', type: 'textarea', default: '', help: '留空则自动生成"本文由 XX 原创发布"' },
|
|
||||||
{ key: 'post_button_toptext', label: '按钮上方文案', type: 'input', default: '喜欢就支持一下吧', help: '显示在点赞/打赏/分享按钮上方' },
|
|
||||||
{ key: 'post_rewards_s', label: '用户打赏', type: 'switch', default: false, help: '开启后,登录用户可使用站内余额赞赏作者,金额直接进入作者余额。' },
|
|
||||||
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true } },
|
|
||||||
{ key: 'post_rewards_amounts', label: '快捷金额', type: 'input', default: '5,10,20,50,100', help: '使用英文逗号分隔', dependsOn: { key: 'post_rewards_s', value: true } },
|
|
||||||
{ key: 'post_rewards_custom', label: '允许自定义金额', type: 'switch', default: true, dependsOn: { key: 'post_rewards_s', value: true } },
|
|
||||||
{ key: 'post_rewards_min', label: '最低金额', type: 'input', default: '1.00', min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
|
||||||
{ key: 'post_rewards_max', label: '最高金额', type: 'input', default: '1000.00', min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
|
||||||
{ key: 'article_maxheight_kg', label: '正文限高', type: 'switch', default: false, help: '开启后长文默认折叠,可展开全文' },
|
|
||||||
{ key: 'article_maxheight', label: '限高像素', type: 'number', default: 1000, min: 200, max: 5000, dependsOn: { key: 'article_maxheight_kg', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
title: '内容插入',
|
|
||||||
icon: 'fa fas fa-pen-square',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_front_content', label: '文章头部内容', type: 'textarea', default: '', help: '显示在所有文章正文前方(支持 HTML)' },
|
|
||||||
{ key: 'post_after_content', label: '文章底部内容', type: 'textarea', default: '', help: '显示在所有文章正文后方(支持 HTML)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'yiyan',
|
|
||||||
title: '一言',
|
|
||||||
icon: 'fa fas fa-quote-left',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_yiyan_header', label: '文章头部一言', type: 'switch', default: false, help: '在文章标题下方显示一言' },
|
|
||||||
{ key: 'post_yiyan_footer', label: '文章底部一言', type: 'switch', default: false, help: '在文章内容底部显示一言' },
|
|
||||||
{ key: 'post_yiyan_box', label: '一言盒子', type: 'switch', default: false, help: '在相关文章前显示一言盒子' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'meta',
|
|
||||||
title: '默认数值',
|
|
||||||
icon: 'fa fas fa-chart-bar',
|
|
||||||
fields: [
|
|
||||||
{ key: 'post_default_views_min', label: '默认浏览最小值', type: 'number', default: 20, min: 0, max: 9999 },
|
|
||||||
{ key: 'post_default_views_max', label: '默认浏览最大值', type: 'number', default: 50, min: 1, max: 9999 },
|
|
||||||
{ key: 'post_default_likes_min', label: '默认点赞最小值', type: 'number', default: 5, min: 0, max: 9999 },
|
|
||||||
{ key: 'post_default_likes_max', label: '默认点赞最大值', type: 'number', default: 15, min: 1, max: 9999 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
export const shareSchema = {
|
|
||||||
key: 'post_list.share',
|
|
||||||
title: '分享设置',
|
|
||||||
icon: 'fa fas fa-share-alt',
|
|
||||||
description: '配置微信分享凭据、海报分享和社交分享选项。',
|
|
||||||
defaults: {
|
|
||||||
share_wechat_s: false,
|
|
||||||
share_wechat_appid: '',
|
|
||||||
share_wechat_app_secret: '',
|
|
||||||
share_only_logo: false,
|
|
||||||
share_img_compatible_s: false,
|
|
||||||
share_img_byimg: '',
|
|
||||||
share_logo: '',
|
|
||||||
share_img: '',
|
|
||||||
share_desc: '扫一扫 立即查看',
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'wechat',
|
|
||||||
title: '微信分享',
|
|
||||||
icon: 'fa fab fa-weixin',
|
|
||||||
fields: [
|
|
||||||
{ key: 'share_wechat_s', label: '启用微信分享', type: 'switch', default: false, help: '启用后文章页将支持微信 JSAPI 分享' },
|
|
||||||
{ key: 'share_wechat_appid', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppID' },
|
|
||||||
{ key: 'share_wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppSecret(请确保 JS 安全域名已配置)' },
|
|
||||||
{ key: 'share_only_logo', label: '仅显示 Logo', type: 'switch', default: false, help: '分享时仅显示站点 Logo' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'poster',
|
|
||||||
title: '海报分享',
|
|
||||||
icon: 'fa fas fa-image',
|
|
||||||
fields: [
|
|
||||||
{ key: 'share_img_compatible_s', label: '兼容模式', type: 'switch', default: false, help: '启用后使用兼容模式生成分享海报' },
|
|
||||||
{ key: 'share_img_byimg', label: '海报默认图', type: 'media', default: '', help: '海报分享使用的默认背景图' },
|
|
||||||
{ key: 'share_logo', label: '海报 LOGO', type: 'media', default: '', help: '海报上叠加的站点 LOGO' },
|
|
||||||
{ key: 'share_img', label: '分享图片', type: 'media', default: '', help: '社交平台分享预览图' },
|
|
||||||
{ key: 'share_desc', label: '分享描述', type: 'textarea', default: '扫一扫 立即查看', help: '海报或分享卡片的描述文案' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -2,123 +2,85 @@ export const submitSchema = {
|
|||||||
key: 'post_list.submit',
|
key: 'post_list.submit',
|
||||||
title: '前台投稿',
|
title: '前台投稿',
|
||||||
icon: 'fa fas fa-paper-plane',
|
icon: 'fa fas fa-paper-plane',
|
||||||
description: '配置前台投稿表单字段、审核流程、附件权限和封面策略。',
|
description: '配置前台投稿的开关、可选分类、免登录发布用户、标题与付费限制。',
|
||||||
defaults: {
|
defaults: {
|
||||||
front_submit_enabled: true,
|
post_article_s: true,
|
||||||
front_submit_roles: 'author,editor,administrator',
|
|
||||||
front_submit_title: true,
|
|
||||||
front_submit_content: true,
|
|
||||||
front_submit_excerpt: true,
|
|
||||||
front_submit_cover: true,
|
|
||||||
front_submit_category: true,
|
|
||||||
post_article_cat: [],
|
|
||||||
front_submit_tags: true,
|
|
||||||
post_article_custom_filter_s: true,
|
|
||||||
front_submit_custom_fields: '',
|
|
||||||
front_submit_review_mode: 'pending',
|
|
||||||
front_submit_cover_required: false,
|
|
||||||
front_submit_max_images: 5,
|
|
||||||
front_submit_editor_mode: 'rich',
|
|
||||||
front_submit_draft_save: true,
|
|
||||||
front_submit_leave_warning: true,
|
|
||||||
front_submit_guest_enabled: false,
|
|
||||||
post_article_btn_txte: '发布文章',
|
post_article_btn_txte: '发布文章',
|
||||||
post_article_title_strlen_limit_min: 5,
|
post_article_cat: [],
|
||||||
post_article_title_strlen_limit_max: 30,
|
post_article_custom_filter_s: true,
|
||||||
post_article_user: 0,
|
post_article_tag: true,
|
||||||
|
post_article_user: 1,
|
||||||
|
post_article_title_strlen_limit: { min: 5, max: 30 },
|
||||||
post_article_pay_free_s: true,
|
post_article_pay_free_s: true,
|
||||||
post_article_pay_price_min: '0.00',
|
post_article_pay_price_limit: { price: { min: 0, max: 99999 }, points: { min: 0, max: 0 } },
|
||||||
post_article_pay_price_max: '100.00',
|
|
||||||
post_article_pay_vip_price_s: true,
|
post_article_pay_vip_price_s: true,
|
||||||
post_article_pay_vip_1_discount: 80,
|
post_article_pay_vip_1_discount: 100,
|
||||||
post_article_pay_vip_2_discount: 50,
|
post_article_pay_vip_2_discount: 100,
|
||||||
|
post_t_placeholder: '请输入标题',
|
||||||
|
post_c_placeholder: '请输入内容',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'submit',
|
||||||
title: '基础配置',
|
title: '',
|
||||||
icon: 'fa fas fa-cog',
|
|
||||||
fields: [
|
|
||||||
{ key: 'front_submit_enabled', label: '开启前台投稿', type: 'switch', default: true, help: '允许用户在前台提交文章' },
|
|
||||||
{ key: 'front_submit_roles', label: '允许投稿的角色', type: 'input', default: 'author,editor,administrator', dependsOn: { key: 'front_submit_enabled', value: true }, help: '逗号分隔角色标识' },
|
|
||||||
{ key: 'post_article_btn_txte', label: '投稿按钮文案', type: 'input', default: '发布文章', dependsOn: { key: 'front_submit_enabled', value: true }, help: '前台投稿按钮显示的文字' },
|
|
||||||
{ key: 'post_article_title_strlen_limit_min', label: '标题最少字数', type: 'number', default: 5, min: 1, max: 100, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_article_title_strlen_limit_max', label: '标题最多字数', type: 'number', default: 30, min: 5, max: 200, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_t_placeholder', label: '标题占位符', type: 'input', default: '', placeholder: '请输入标题', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_c_placeholder', label: '内容占位符', type: 'textarea', default: '', placeholder: '请输入内容', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'fields',
|
|
||||||
title: '表单字段',
|
|
||||||
icon: 'fa fas fa-list-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'front_submit_title', label: '标题', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_content', label: '正文', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_excerpt', label: '摘要', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_cover', label: '封面图', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_category', label: '分类选择', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_article_cat', label: '允许投稿分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '为空则允许全部分类', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_guest_enabled', label: '允许免登录投稿', type: 'switch', default: false, help: '开启后访客可投稿;文章归属到下方用户', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_article_user', label: '免登录归属用户', type: 'user_select', default: 0, help: '免登录投稿时文章归属的用户,搜索选择', dependsOn: { key: 'front_submit_guest_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_tags', label: '标签输入', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'post_article_custom_filter_s', label: '高级筛选选择', type: 'switch', default: true, help: '投稿时可选择自定义筛选维度值', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
{ key: 'front_submit_custom_fields', label: '自定义字段', type: 'textarea', default: '', placeholder: '每行一个:field_key|字段名称|类型(input/textarea/select)', dependsOn: { key: 'front_submit_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'review',
|
|
||||||
title: '审核策略',
|
|
||||||
icon: 'fa fas fa-tasks',
|
|
||||||
fields: [
|
fields: [
|
||||||
|
{ key: 'post_article_s', label: '启用前台投稿', type: 'switch', default: true },
|
||||||
{
|
{
|
||||||
key: 'front_submit_review_mode',
|
key: 'submit_perm_notice',
|
||||||
label: '审核模式',
|
label: '',
|
||||||
type: 'radio',
|
type: 'content',
|
||||||
default: 'pending',
|
ui_only: true,
|
||||||
options: [
|
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;">此功能启用后,可以在<a href="admin.php?page=aksrt-settings#/permissions/capabilities">权限管理</a>中设置用户的发布、审核权限 <a href="admin.php?page=aksrt-settings#/permissions/capabilities">【去设置】</a></p>',
|
||||||
{ label: '待审核', value: 'pending' },
|
|
||||||
{ label: '直接发布', value: 'publish' },
|
|
||||||
{ label: '自动草稿', value: 'draft' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'front_submit_enabled', value: true },
|
|
||||||
},
|
},
|
||||||
{ key: 'front_submit_cover_required', label: '封面图必填', type: 'switch', default: false, dependsOn: { key: 'front_submit_cover', value: true } },
|
{ key: 'post_article_btn_txte', label: '按钮文字', type: 'input', default: '发布文章', dependsOn: { key: 'post_article_s', value: true }, description: '投稿按钮的显示的文字,推荐4个字更加协调' },
|
||||||
{ key: 'front_submit_max_images', label: '正文最大图片数', type: 'number', default: 5, min: 0, max: 50, dependsOn: { key: 'front_submit_enabled', value: true } },
|
{ key: 'post_article_cat', label: '投稿允许选择的分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], placeholder: '输入关键词以搜索分类', dependsOn: { key: 'post_article_s', value: true }, description: '投稿允许选择的分类,为空则允许选择全部分类,所选分类下如果有子分类则会自动显示,如果选择了上级分类,则不要再选该分类下的子分类。注意:请确保所选分类包含子分类总数不要超过50个,否则可能会引起页面崩溃' },
|
||||||
],
|
{ key: 'post_article_custom_filter_s', label: '允许选择高级筛选', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true } },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'editor',
|
|
||||||
title: '编辑器设置',
|
|
||||||
icon: 'fa fas fa-edit',
|
|
||||||
fields: [
|
|
||||||
{
|
{
|
||||||
key: 'front_submit_editor_mode',
|
key: 'submit_filter_notice',
|
||||||
label: '编辑器模式',
|
label: '',
|
||||||
type: 'radio',
|
type: 'content',
|
||||||
default: 'rich',
|
ui_only: true,
|
||||||
options: [
|
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;">此功能启用后,请先在<a href="admin.php?page=aksrt-settings#/post-list/filter">文章列表/高级筛选</a>中添加高级筛选明细 <a href="admin.php?page=aksrt-settings#/post-list/filter">【去设置】</a></p>',
|
||||||
{ label: '富文本', value: 'rich' },
|
|
||||||
{ label: 'Markdown', value: 'markdown' },
|
|
||||||
{ label: '纯文本', value: 'plain' },
|
|
||||||
],
|
|
||||||
dependsOn: { key: 'front_submit_content', value: true },
|
|
||||||
},
|
},
|
||||||
{ key: 'front_submit_draft_save', label: '自动保存草稿', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
{ key: 'post_article_tag', label: '允许插入标签', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '前台投稿时候允许设置文章标签' },
|
||||||
{ key: 'front_submit_leave_warning', label: '离开页面提醒', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
{ key: 'post_article_user', label: '免登陆投稿发布用户', type: 'user_select', default: 1, dependsOn: { key: 'post_article_s', value: true }, description: '当选择投稿权限为无需登录就能投稿时,投稿文章的用户' },
|
||||||
],
|
{ key: 'post_article_title_strlen_limit', label: '标题字数限制', type: 'between_number', default: { min: 5, max: 30 }, unit: '字', description: '限制标题字数可有效的防止灌水等无意义内容(英文字符按0.5个字计算)' },
|
||||||
},
|
{ key: 'post_article_pay_free_s', label: '允许设置为免费资源', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '付费内容金额允许设置为 0' },
|
||||||
{
|
{
|
||||||
key: 'paid',
|
key: 'post_article_pay_price_limit',
|
||||||
title: '付费投稿权限',
|
label: '付费内容金额限制',
|
||||||
icon: 'fa fas fa-database',
|
type: 'fieldset',
|
||||||
fields: [
|
default: { price: { min: 0, max: 99999 }, points: { min: 0, max: 0 } },
|
||||||
{ key: 'post_article_pay_free_s', label: '允许设置免费', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
description: '限制用户允许设置的金额,防止金额过高不合理(填0为不限制)',
|
||||||
{ key: 'post_article_pay_price_min', label: '最低价格', type: 'input', sanitize_type: 'money_decimal', default: '0.00', min: 0, max: 9999, help: '投稿可设置的最低付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
fields: [
|
||||||
{ key: 'post_article_pay_price_max', label: '最高价格', type: 'input', sanitize_type: 'money_decimal', default: '100.00', min: 0, max: 9999, help: '投稿可设置的最高付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
{ key: 'price', label: '现金金额限制', type: 'between_number', default: { min: 0, max: 99999 } },
|
||||||
{ key: 'post_article_pay_vip_price_s', label: '允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
{ key: 'points', label: '积分金额限制', type: 'between_number', default: { min: 0, max: 0 } },
|
||||||
{ key: 'post_article_pay_vip_1_discount', label: 'VIP1 折扣(%)', type: 'number', default: 80, min: 0, max: 100, help: 'VIP1 会员折扣百分比,80 即 8 折', dependsOn: { key: 'front_submit_enabled', value: true } },
|
],
|
||||||
{ key: 'post_article_pay_vip_2_discount', label: 'VIP2 折扣(%)', type: 'number', default: 50, min: 0, max: 100, help: 'VIP2 会员折扣百分比,50 即 5 折', dependsOn: { key: 'front_submit_enabled', value: true } },
|
},
|
||||||
|
{ key: 'post_article_pay_vip_price_s', label: '付费内容允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '前台投稿时对拥有设置付费内容权限的用户,是否开启设置会员价格。开启此项,会直接在前台显示设置会员价的选项;关闭此项,则用户只能设置普通价格,会员价则按照下方设置的折扣自动计算' },
|
||||||
|
{
|
||||||
|
key: 'post_article_pay_vip_1_discount',
|
||||||
|
label: '一级会员折扣',
|
||||||
|
type: 'number',
|
||||||
|
default: 100,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
unit: '%',
|
||||||
|
dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'post_article_pay_vip_2_discount',
|
||||||
|
label: '二级会员折扣',
|
||||||
|
type: 'number',
|
||||||
|
default: 100,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
unit: '%',
|
||||||
|
dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
|
||||||
|
description: '执行价的百分之多少,0为免费,100为没有折扣,不能高于100',
|
||||||
|
},
|
||||||
|
{ key: 'post_t_placeholder', label: '投稿标题占位符', type: 'input', default: '请输入标题' },
|
||||||
|
{ key: 'post_c_placeholder', label: '投稿内容占位符', type: 'input', default: '请输入内容' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const authSchema = {
|
|||||||
invit_code_s: 'close',
|
invit_code_s: 'close',
|
||||||
invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送',
|
invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送',
|
||||||
invit_code_must_desc: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>',
|
invit_code_must_desc: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>',
|
||||||
|
invite_code_s: 'close',
|
||||||
|
invite_code_open_desc: '请输入邀请码,注册成功后奖励将自动发放',
|
||||||
|
invite_code_must_desc: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>',
|
||||||
user_signin_phone_s: false,
|
user_signin_phone_s: false,
|
||||||
user_signin_nopas_s: false,
|
user_signin_nopas_s: false,
|
||||||
user_signin_nopas_type: 'email',
|
user_signin_nopas_type: 'email',
|
||||||
@@ -84,6 +87,16 @@ export const authSchema = {
|
|||||||
{ key: 'invit_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', dependsOn: { key: 'invit_code_s', value: 'must' }, help: '支持安全 HTML' },
|
{ key: 'invit_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', dependsOn: { key: 'invit_code_s', value: 'must' }, help: '支持安全 HTML' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'invite_code',
|
||||||
|
title: '邀请码注册',
|
||||||
|
icon: 'fa fas fa-envelope-open-text',
|
||||||
|
fields: [
|
||||||
|
{ key: 'invite_code_s', label: '邀请码注册模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '选填邀请码', value: 'open' }, { label: '必须填写邀请码', value: 'must' }], help: '一次性邀请码与推广码相互独立:邀请码用于控制注册并按码发放奖励,在「用户 → 邀请码管理」批量生成' },
|
||||||
|
{ key: 'invite_code_open_desc', label: '选填提示文案', type: 'input', default: '请输入邀请码,注册成功后奖励将自动发放', dependsOn: { key: 'invite_code_s', value: 'open' } },
|
||||||
|
{ key: 'invite_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>', dependsOn: { key: 'invite_code_s', value: 'must' }, help: '支持安全 HTML' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'verification',
|
key: 'verification',
|
||||||
title: '登录与验证',
|
title: '登录与验证',
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export const creditsSchema = {
|
|||||||
points_deduction_s: false,
|
points_deduction_s: false,
|
||||||
points_deduction_rate: 100,
|
points_deduction_rate: 100,
|
||||||
points_deduction_max: 50,
|
points_deduction_max: 50,
|
||||||
asset_points_card_enabled: false,
|
|
||||||
asset_points_card_description: '',
|
|
||||||
asset_points_card_password_only: true,
|
|
||||||
points_show_balance: true,
|
points_show_balance: true,
|
||||||
points_show_log: true,
|
points_show_log: true,
|
||||||
points_expire_days: 0,
|
points_expire_days: 0,
|
||||||
@@ -90,16 +87,6 @@ export const creditsSchema = {
|
|||||||
{ key: 'points_deduction_max', label: '最大抵扣比例(%)', type: 'number', default: 50, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true }, help: '预留:最多可抵订单金额的百分比。当前未参与下单计算。' },
|
{ key: 'points_deduction_max', label: '最大抵扣比例(%)', type: 'number', default: 50, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true }, help: '预留:最多可抵订单金额的百分比。当前未参与下单计算。' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'card_pass',
|
|
||||||
title: '积分卡密兑换',
|
|
||||||
icon: 'fa fas fa-ticket-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'asset_points_card_enabled', label: '启用积分卡密兑换', type: 'switch', default: false, help: '后台充值卡可生成积分卡,用户中心兑换入账。' },
|
|
||||||
{ key: 'asset_points_card_description', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'asset_points_card_enabled', value: true } },
|
|
||||||
{ key: 'asset_points_card_password_only', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'asset_points_card_enabled', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'display',
|
key: 'display',
|
||||||
title: '展示设置',
|
title: '展示设置',
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export const featuresSchema = {
|
|||||||
user_cover_upload_s: true,
|
user_cover_upload_s: true,
|
||||||
user_cover_img: `${themeRoot}/assets/img/user_t.jpg`,
|
user_cover_img: `${themeRoot}/assets/img/user_t.jpg`,
|
||||||
user_desc_std: '这家伙很懒,什么都没有写...',
|
user_desc_std: '这家伙很懒,什么都没有写...',
|
||||||
|
post_rewards_s: true,
|
||||||
|
post_rewards_text: '赞赏',
|
||||||
|
post_rewards_amounts: '5,10,20,50,100',
|
||||||
|
post_rewards_custom: true,
|
||||||
|
post_rewards_min: '1.00',
|
||||||
|
post_rewards_max: '1000.00',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
@@ -45,5 +51,18 @@ export const featuresSchema = {
|
|||||||
{ key: 'user_desc_std', label: '用户默认签名', type: 'input', default: '这家伙很懒,什么都没有写...', help: '用户未设置个人简介时显示的默认文案。' },
|
{ key: 'user_desc_std', label: '用户默认签名', type: 'input', default: '这家伙很懒,什么都没有写...', help: '用户未设置个人简介时显示的默认文案。' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'rewards',
|
||||||
|
title: '打赏',
|
||||||
|
icon: 'fa fas fa-hand-holding-heart',
|
||||||
|
fields: [
|
||||||
|
{ key: 'post_rewards_s', label: '用户打赏功能', type: 'switch', default: true, help: '开启后,登录用户可使用站内余额赞赏作者,金额直接进入作者余额。' },
|
||||||
|
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true }, help: '自定义打赏按钮文字' },
|
||||||
|
{ key: 'post_rewards_amounts', label: '快捷金额', type: 'input', default: '5,10,20,50,100', dependsOn: { key: 'post_rewards_s', value: true }, help: '使用英文逗号分隔' },
|
||||||
|
{ key: 'post_rewards_custom', label: '允许自定义金额', type: 'switch', default: true, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||||
|
{ key: 'post_rewards_min', label: '最低金额', type: 'input', default: '1.00', min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||||
|
{ key: 'post_rewards_max', label: '最高金额', type: 'input', default: '1000.00', min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,6 @@ import { featuresSchema } from './features.js'
|
|||||||
import { verifySchema } from './verify.js'
|
import { verifySchema } from './verify.js'
|
||||||
import { levelSchema } from './level.js'
|
import { levelSchema } from './level.js'
|
||||||
import { badgeSchema } from './badge.js'
|
import { badgeSchema } from './badge.js'
|
||||||
import { wechatSchema } from './wechat.js'
|
|
||||||
import { reportSchema } from './report.js'
|
import { reportSchema } from './report.js'
|
||||||
import { smsSchema } from './sms.js'
|
import { smsSchema } from './sms.js'
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export const usersSchemas = {
|
|||||||
verify: verifySchema,
|
verify: verifySchema,
|
||||||
level: levelSchema,
|
level: levelSchema,
|
||||||
badge: badgeSchema,
|
badge: badgeSchema,
|
||||||
wechat: wechatSchema,
|
|
||||||
report: reportSchema,
|
report: reportSchema,
|
||||||
sms: smsSchema,
|
sms: smsSchema,
|
||||||
}
|
}
|
||||||
@@ -1,57 +1,71 @@
|
|||||||
|
// 字段集合、顺序、文案、默认值 1:1 对齐参考主题"用户&互动/禁封/举报"
|
||||||
export const reportSchema = {
|
export const reportSchema = {
|
||||||
key: 'users.report',
|
key: 'users.report',
|
||||||
title: '禁封/举报',
|
title: '禁封/举报',
|
||||||
icon: 'fa fas fa-flag',
|
icon: 'fa fas fa-ban',
|
||||||
description: '配置举报入口、公开限制提示和后续治理分类,为封禁/举报流程预留稳定结构。',
|
description: '禁封功能、举报入口、预置原因、申诉资料与举报说明配置。',
|
||||||
defaults: {
|
defaults: {
|
||||||
user_report_enabled: false,
|
user_ban_s: true,
|
||||||
user_report_public_entry: true,
|
user_report_s: true,
|
||||||
user_report_public_notice: true,
|
ban_preset_reason: [
|
||||||
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。',
|
{ t: '发布色情、违法内容' },
|
||||||
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉',
|
{ t: '存在欺诈骗钱行为' },
|
||||||
ban_preset_reason: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作',
|
{ t: '骚扰他人' },
|
||||||
ban_appeal_desc: '',
|
{ t: '涉嫌侵权' },
|
||||||
ban_appeal_keys: '',
|
{ t: '发布垃圾广告信息' },
|
||||||
|
],
|
||||||
|
ban_appeal_desc: '<div>为了良好的交流环境,申诉请阅读以下事项</div>\n<li>首次违规将拉入小黑屋7天</li>\n<li>3次违规后将直接禁封账号</li>\n<li>违规情节严重将无法提交申诉</li>\n<li>申诉提交后约2-3个工作日进行审核</li>\n<li>如有其它疑问请与客服联系</li>',
|
||||||
|
ban_appeal_keys: [
|
||||||
|
{ t: '真实姓名' },
|
||||||
|
{ t: '邮箱' },
|
||||||
|
{ t: '手机号' },
|
||||||
|
],
|
||||||
user_report_img_s: true,
|
user_report_img_s: true,
|
||||||
user_report_desc: '',
|
user_report_desc: '<div>举报说明:</div>\n<li>请确保举报的内容属实</li>\n<li>我们将会在收到举报后2个工作日内进行处理</li>\n<li>举报属实我们将根据社区管理条例对该用户进行封号处理</li>\n<li>如有其它疑问请与客服联系</li>',
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'entry',
|
key: 'main',
|
||||||
title: '前台入口',
|
title: '',
|
||||||
icon: 'fa fas fa-sign-in-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'user_report_enabled', label: '启用举报/禁封结构', type: 'switch', default: false },
|
|
||||||
{ key: 'user_report_public_entry', label: '公开主页展示举报入口', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } },
|
|
||||||
{ key: 'user_report_public_notice', label: '公开显示限制提示', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } },
|
|
||||||
{ key: 'user_report_ban_notice_text', label: '限制提示文案', type: 'input', default: '当前账号处于限制状态,部分互动能力已关闭。', dependsOn: { key: 'user_report_public_notice', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'taxonomy',
|
|
||||||
title: '举报分类',
|
|
||||||
icon: 'fa fas fa-list',
|
|
||||||
fields: [
|
|
||||||
{ key: 'user_report_categories', label: '举报分类', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉', help: '每行一个分类:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ban',
|
|
||||||
title: '禁封设置',
|
|
||||||
icon: 'fa fas fa-ban',
|
icon: 'fa fas fa-ban',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'ban_preset_reason', label: '预设封禁原因', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作', help: '每行一条:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } },
|
{ key: 'user_ban_s', label: '禁封功能', type: 'switch', default: true, help: '启用后可以设置用户禁封,即小黑屋和封号的功能' },
|
||||||
{ key: 'ban_appeal_desc', label: '申诉说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_enabled', value: true } },
|
{ key: 'user_report_s', label: '举报功能', type: 'switch', default: true, help: '前台显示举报按钮,用户可以举报其它不良信息', dependsOn: { key: 'user_ban_s', value: true } },
|
||||||
{ key: 'ban_appeal_keys', label: '申诉关键词', type: 'input', default: '', help: '逗号分隔', dependsOn: { key: 'user_report_enabled', value: true } },
|
{
|
||||||
],
|
key: 'ban_preset_reason',
|
||||||
},
|
label: '预置原因',
|
||||||
{
|
type: 'group',
|
||||||
key: 'image',
|
button_title: '添加原因',
|
||||||
title: '图片举报',
|
help: '举报/禁封的时候可选择的原因',
|
||||||
icon: 'fa fas fa-image',
|
default: [
|
||||||
fields: [
|
{ t: '发布色情、违法内容' },
|
||||||
{ key: 'user_report_img_s', label: '启用图片举报', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } },
|
{ t: '存在欺诈骗钱行为' },
|
||||||
{ key: 'user_report_desc', label: '举报说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_img_s', value: true } },
|
{ t: '骚扰他人' },
|
||||||
|
{ t: '涉嫌侵权' },
|
||||||
|
{ t: '发布垃圾广告信息' },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: 't', label: '原因', type: 'input', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'ban_appeal_desc', label: '申诉说明', type: 'textarea', rows: 5, help: '用户申诉时候显示的内容(支持HTML代码,请注意代码规范)', default: '<div>为了良好的交流环境,申诉请阅读以下事项</div>\n<li>首次违规将拉入小黑屋7天</li>\n<li>3次违规后将直接禁封账号</li>\n<li>违规情节严重将无法提交申诉</li>\n<li>申诉提交后约2-3个工作日进行审核</li>\n<li>如有其它疑问请与客服联系</li>' },
|
||||||
|
{
|
||||||
|
key: 'ban_appeal_keys',
|
||||||
|
label: '申诉所需资料',
|
||||||
|
type: 'group',
|
||||||
|
button_title: '添加申诉资料',
|
||||||
|
help: '用户提交申诉时候需要提供的资料;此处的信息收集,仅用于记录和申诉审核',
|
||||||
|
default: [
|
||||||
|
{ t: '真实姓名' },
|
||||||
|
{ t: '邮箱' },
|
||||||
|
{ t: '手机号' },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: 't', label: '资料项', type: 'input', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'user_report_img_s', label: '举报可上传图片', type: 'switch', default: true, help: '举报时允许上传图片进行举证(最多上传3张)', dependsOn: { key: 'user_report_s', value: true } },
|
||||||
|
{ key: 'user_report_desc', label: '举报说明', type: 'textarea', rows: 5, help: '举报时候显示的内容(支持HTML代码,请注意代码规范)', dependsOn: { key: 'user_report_s', value: true }, default: '<div>举报说明:</div>\n<li>请确保举报的内容属实</li>\n<li>我们将会在收到举报后2个工作日内进行处理</li>\n<li>举报属实我们将根据社区管理条例对该用户进行封号处理</li>\n<li>如有其它疑问请与客服联系</li>' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,142 +2,103 @@ export const smsSchema = {
|
|||||||
key: 'users.sms',
|
key: 'users.sms',
|
||||||
title: '短信接口',
|
title: '短信接口',
|
||||||
icon: 'fa fas fa-comment',
|
icon: 'fa fas fa-comment',
|
||||||
description: '配置短信验证码和短信通知服务的供应商、模板和限流策略。',
|
description: '设置手机验证码服务。验证码为 6 位数字,有效期 30 分钟。',
|
||||||
defaults: {
|
defaults: {
|
||||||
sms_enabled: false,
|
sms_sdk: 'null',
|
||||||
sms_provider: 'aliyun',
|
|
||||||
sms_number_limit_ch: true,
|
sms_number_limit_ch: true,
|
||||||
sms_access_key: '',
|
|
||||||
sms_access_secret: '',
|
|
||||||
sms_sign_name: '',
|
|
||||||
sms_template_code: '',
|
|
||||||
sms_code_length: 6,
|
|
||||||
sms_code_expire: 300,
|
|
||||||
sms_rate_limit: 60,
|
|
||||||
sms_daily_limit: 10,
|
|
||||||
sms_notify_bind: true,
|
|
||||||
sms_notify_login: false,
|
|
||||||
sms_notify_register: true,
|
|
||||||
sms_notify_reset_password: true,
|
|
||||||
sms_ali_api_type: 'sms',
|
sms_ali_api_type: 'sms',
|
||||||
|
sms_ali_keyid: '',
|
||||||
|
sms_ali_keysecret: '',
|
||||||
|
sms_ali_sign_name: '',
|
||||||
|
sms_ali_template_code: '',
|
||||||
sms_region_id: 'cn-hangzhou',
|
sms_region_id: 'cn-hangzhou',
|
||||||
sms_tencent_app_id: '',
|
sms_tencent_app_id: '',
|
||||||
sms_tencent_app_key: '',
|
sms_tencent_app_key: '',
|
||||||
sms_tencent_secret_id: '',
|
sms_tencent_sign_name: '',
|
||||||
sms_tencent_secret_key: '',
|
sms_tencent_template_id: '',
|
||||||
sms_tencent_region: 'ap-guangzhou',
|
sms_smsbao_username: '',
|
||||||
sms_huawei_sender: '',
|
sms_smsbao_password: '',
|
||||||
sms_huawei_signature: '',
|
sms_smsbao_api_key: '',
|
||||||
sms_huawei_endpoint: '',
|
sms_smsbao_template: '',
|
||||||
sms_custom_url: '',
|
|
||||||
sms_custom_method: 'POST',
|
|
||||||
sms_custom_auth_type: 'header',
|
|
||||||
sms_custom_auth_key: '',
|
|
||||||
sms_custom_auth_value: '',
|
|
||||||
sms_custom_headers: '',
|
|
||||||
sms_custom_body: '',
|
|
||||||
sms_custom_success_rule: '',
|
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
key: 'provider',
|
key: 'provider',
|
||||||
title: '供应商配置',
|
title: '短信接口',
|
||||||
icon: 'fa fas fa-plug',
|
icon: 'fa fas fa-plug',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'sms_enabled', label: '启用短信服务', type: 'switch', default: false, help: '开启后支持短信验证码和短信通知' },
|
|
||||||
{
|
{
|
||||||
key: 'sms_provider',
|
key: 'sms_sdk',
|
||||||
label: '短信供应商',
|
label: '设置短信接口',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
default: 'aliyun',
|
default: 'null',
|
||||||
options: [
|
options: [
|
||||||
{ label: '阿里云短信', value: 'aliyun' },
|
{ label: '不使用', value: 'null' },
|
||||||
|
{ label: '阿里云短信', value: 'ali' },
|
||||||
{ label: '腾讯云短信', value: 'tencent' },
|
{ label: '腾讯云短信', value: 'tencent' },
|
||||||
{ label: '华为云短信', value: 'huawei' },
|
{ label: '短信宝', value: 'smsbao' },
|
||||||
{ label: '自定义接口', value: 'custom' },
|
|
||||||
],
|
],
|
||||||
dependsOn: { key: 'sms_enabled', value: true },
|
|
||||||
},
|
},
|
||||||
{ key: 'sms_number_limit_ch', label: '仅发国内', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true }, help: '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信' },
|
{
|
||||||
{ key: 'sms_access_key', label: 'AccessKey / SecretId / AppKey', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '阿里云填 AccessKey,腾讯云填 SecretId,华为云填 AppKey' },
|
key: 'sms_number_limit_ch',
|
||||||
{ key: 'sms_access_secret', label: 'AccessSecret / SecretKey / AppSecret', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_enabled', value: true } },
|
label: '限制国内手机号',
|
||||||
|
type: 'switch',
|
||||||
|
default: true,
|
||||||
|
help: '开启后只允许发送到中国大陆手机号。',
|
||||||
|
},
|
||||||
|
{ key: 'sms_test_phone', label: '测试手机号', type: 'input', default: '', ui_only: true, help: '保存接口配置后,发送固定验证码 888888 测试短信。' },
|
||||||
|
{ key: 'sms_test_send', label: '发送测试短信', type: 'button', buttonType: 'primary', buttonText: '发送测试短信', ui_only: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'aliyun',
|
key: 'aliyun',
|
||||||
title: '阿里云配置',
|
title: '阿里云',
|
||||||
icon: 'fa fas fa-cloud',
|
icon: 'fa fas fa-cloud',
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'sms_ali_api_type', label: '接口类型', type: 'radio', default: 'sms', options: [{ label: '短信服务', value: 'sms' }, { label: '号码认证', value: 'verify' }], dependsOn: { key: 'sms_provider', value: 'aliyun' } },
|
{
|
||||||
{ key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_provider', value: 'aliyun' } },
|
key: 'sms_ali_api_type',
|
||||||
|
label: '产品',
|
||||||
|
type: 'radio',
|
||||||
|
default: 'sms',
|
||||||
|
options: [
|
||||||
|
{ label: '短信服务', value: 'sms' },
|
||||||
|
{ label: '号码认证:短信认证服务', value: 'verify' },
|
||||||
|
],
|
||||||
|
dependsOn: { key: 'sms_sdk', value: 'ali' },
|
||||||
|
},
|
||||||
|
{ key: 'sms_ali_keyid', label: 'AccessKey Id', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
|
||||||
|
{ key: 'sms_ali_keysecret', label: 'AccessKey Secret', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
|
||||||
|
{ key: 'sms_ali_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
|
||||||
|
{ key: 'sms_ali_template_code', label: '模板 CODE', type: 'input', default: '', dependsOn: { all: [{ key: 'sms_sdk', value: 'ali' }, { key: 'sms_ali_api_type', not: true, value: 'verify' }] }, help: '短信服务需要填写。号码认证产品固定使用平台模板。' },
|
||||||
|
{ key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_sdk', value: 'ali' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tencent',
|
key: 'tencent',
|
||||||
title: '腾讯云配置',
|
title: '腾讯云',
|
||||||
icon: 'fa fas fa-cloud',
|
icon: 'fa fas fa-cloud',
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'sms_tencent_app_id', label: 'SdkAppId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' }, help: '腾讯云短信应用 ID' },
|
{ key: 'sms_tencent_app_id', label: 'SDK AppID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
|
||||||
{ key: 'sms_tencent_app_key', label: 'AppKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
|
{ key: 'sms_tencent_app_key', label: 'App Key', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
|
||||||
{ key: 'sms_tencent_secret_id', label: 'SecretId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
|
{ key: 'sms_tencent_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
|
||||||
{ key: 'sms_tencent_secret_key', label: 'SecretKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
|
{ key: 'sms_tencent_template_id', label: '模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' }, help: '模板包含验证码和有效分钟数两个参数。' },
|
||||||
{ key: 'sms_tencent_region', label: '地域', type: 'input', default: 'ap-guangzhou', dependsOn: { key: 'sms_provider', value: 'tencent' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'huawei',
|
key: 'smsbao',
|
||||||
title: '华为云配置',
|
title: '短信宝',
|
||||||
icon: 'fa fas fa-cloud',
|
icon: 'fa fas fa-cloud',
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'sms_huawei_sender', label: '通道号 (sender)', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' } },
|
{ key: 'sms_smsbao_username', label: '用户名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } },
|
||||||
{ key: 'sms_huawei_signature', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '华为云短信签名内容' },
|
{ key: 'sms_smsbao_password', label: '密码', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } },
|
||||||
{ key: 'sms_huawei_endpoint', label: 'API Endpoint', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '留空使用默认 endpoint' },
|
{ key: 'sms_smsbao_api_key', label: 'ApiKey', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '可选;填写后以 ApiKey 认证,可与密码任选其一。' },
|
||||||
],
|
{ key: 'sms_smsbao_template', label: '模板内容', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '必须包含 {code},有效时间变量为 {time}。' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'custom',
|
|
||||||
title: '自定义接口配置',
|
|
||||||
icon: 'fa fas fa-plug',
|
|
||||||
fields: [
|
|
||||||
{ key: 'sms_custom_url', label: '请求 URL', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' },
|
|
||||||
{ key: 'sms_custom_method', label: '请求方法', type: 'select', default: 'POST', options: [{ label: 'POST', value: 'POST' }, { label: 'GET', value: 'GET' }, { label: 'PUT', value: 'PUT' }], dependsOn: { key: 'sms_provider', value: 'custom' } },
|
|
||||||
{ key: 'sms_custom_auth_type', label: '鉴权方式', type: 'select', default: 'header', options: [{ label: 'Header', value: 'header' }], dependsOn: { key: 'sms_provider', value: 'custom' } },
|
|
||||||
{ key: 'sms_custom_auth_key', label: '鉴权 Header 名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' } },
|
|
||||||
{ key: 'sms_custom_auth_value', label: '鉴权 Header 值', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' },
|
|
||||||
{ key: 'sms_custom_headers', label: '自定义 Headers', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '每行一个,格式:Key: Value' },
|
|
||||||
{ key: 'sms_custom_body', label: '请求 Body', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: 'JSON 格式,支持 {phone} {code} {time} 变量' },
|
|
||||||
{ key: 'sms_custom_success_rule', label: '成功判定规则', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '格式:status:200 或 json:code=0 或 contains:success' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'template',
|
|
||||||
title: '短信模板',
|
|
||||||
icon: 'fa fas fa-comment',
|
|
||||||
fields: [
|
|
||||||
{ key: 'sms_sign_name', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '在短信服务商申请的短信签名(所有供应商共用此字段)' },
|
|
||||||
{ key: 'sms_template_code', label: '验证码模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '所有供应商共用此模板 ID' },
|
|
||||||
{ key: 'sms_code_length', label: '验证码长度', type: 'number', default: 6, min: 4, max: 8, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
{ key: 'sms_code_expire', label: '验证码有效期 (秒)', type: 'number', default: 300, min: 60, max: 600, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'rate_limit',
|
|
||||||
title: '限流策略',
|
|
||||||
icon: 'fa fas fa-shield-alt',
|
|
||||||
fields: [
|
|
||||||
{ key: 'sms_rate_limit', label: '发送间隔 (秒)', type: 'number', default: 60, min: 10, max: 300, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号两次发送的最小间隔' },
|
|
||||||
{ key: 'sms_daily_limit', label: '每日发送上限', type: 'number', default: 10, min: 1, max: 100, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号每天最多发送条数' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'triggers',
|
|
||||||
title: '通知场景',
|
|
||||||
icon: 'fa fas fa-bell',
|
|
||||||
fields: [
|
|
||||||
{ key: 'sms_notify_bind', label: '绑定手机时验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
{ key: 'sms_notify_login', label: '短信登录', type: 'switch', default: false, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
{ key: 'sms_notify_register', label: '注册验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
{ key: 'sms_notify_reset_password', label: '找回密码', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,83 +2,52 @@ export const verifySchema = {
|
|||||||
key: 'users.verify',
|
key: 'users.verify',
|
||||||
title: '身份认证',
|
title: '身份认证',
|
||||||
icon: 'fa fas fa-id-card',
|
icon: 'fa fas fa-id-card',
|
||||||
description: '配置公开认证徽标、申请类型与申请门槛;审核、驳回和取消认证请前往 WordPress 用户 → 身份认证。',
|
description: '设置通用身份认证的申请条件;用户提交资料后由管理员审核。',
|
||||||
defaults: {
|
defaults: {
|
||||||
user_verify_enabled: true,
|
user_auth_s: true,
|
||||||
user_verify_public_display: true,
|
user_auth_apply_s: true,
|
||||||
user_verify_personal_badge_text: '个人认证',
|
user_auth_apply_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
||||||
user_verify_personal_fee: '0.00',
|
user_auth_apply_limit_level: 0,
|
||||||
user_verify_personal_free_applications: 0,
|
user_auth_apply_desc: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
|
||||||
user_verify_personal_require_real_name: false,
|
|
||||||
user_verify_enterprise_badge_text: '企业认证',
|
|
||||||
user_verify_enterprise_fee: '0.00',
|
|
||||||
user_verify_enterprise_free_applications: 0,
|
|
||||||
user_verify_enterprise_require_real_name: false,
|
|
||||||
user_verify_allow_apply: true,
|
|
||||||
user_verify_disable_desc:
|
|
||||||
'由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
|
||||||
user_verify_desc:
|
|
||||||
'认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
|
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
|
||||||
key: 'display',
|
|
||||||
title: '公开展示',
|
|
||||||
icon: 'fa fas fa-eye',
|
|
||||||
fields: [
|
|
||||||
{ key: 'user_verify_enabled', label: '启用身份认证', type: 'switch', default: true },
|
|
||||||
{
|
|
||||||
key: 'user_verify_public_display',
|
|
||||||
label: '公开页显示认证徽标',
|
|
||||||
type: 'switch',
|
|
||||||
default: true,
|
|
||||||
dependsOn: { key: 'user_verify_enabled', value: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user_verify_personal_badge_text', label: '个人认证徽标文案', type: 'input', default: '个人认证',
|
|
||||||
dependsOn: { key: 'user_verify_public_display', value: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user_verify_enterprise_badge_text', label: '企业认证徽标文案', type: 'input', default: '企业认证',
|
|
||||||
dependsOn: { key: 'user_verify_public_display', value: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'workflow', title: '认证方式', icon: 'fa fas fa-tasks',
|
|
||||||
fields: [{ type: 'alert', content: '认证方式固定为个人认证和企业认证,申请人不可自行新增或修改类型。' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'apply',
|
key: 'apply',
|
||||||
title: '申请设置',
|
title: '认证申请',
|
||||||
icon: 'fa fas fa-edit',
|
icon: 'fa fas fa-edit',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ key: 'user_auth_s', label: '启用身份认证', type: 'switch', default: true },
|
||||||
{
|
{
|
||||||
key: 'user_verify_allow_apply',
|
key: 'user_auth_apply_s',
|
||||||
label: '允许用户前台申请',
|
label: '允许用户前台申请',
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
default: true,
|
default: true,
|
||||||
dependsOn: { key: 'user_verify_enabled', value: true },
|
dependsOn: { key: 'user_auth_s', value: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'user_verify_disable_desc',
|
key: 'user_auth_apply_disable_desc',
|
||||||
label: '停用申请说明',
|
label: '停用申请说明',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
||||||
dependsOn: { key: 'user_verify_allow_apply', value: false },
|
dependsOn: { key: 'user_auth_apply_s', value: false },
|
||||||
},
|
},
|
||||||
{ key: 'user_verify_personal_fee', label: '个人认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{ key: 'user_verify_personal_free_applications', label: '个人认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{ key: 'user_verify_personal_require_real_name', label: '个人认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{ key: 'user_verify_enterprise_fee', label: '企业认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{ key: 'user_verify_enterprise_free_applications', label: '企业认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{ key: 'user_verify_enterprise_require_real_name', label: '企业认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
|
||||||
{
|
{
|
||||||
key: 'user_verify_desc',
|
key: 'user_auth_apply_limit_level',
|
||||||
|
label: '申请最低用户等级',
|
||||||
|
type: 'number',
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 30,
|
||||||
|
help: '设置为 0 时不限制用户等级。',
|
||||||
|
dependsOn: { key: 'user_auth_apply_s', value: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user_auth_apply_desc',
|
||||||
label: '申请介绍',
|
label: '申请介绍',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
default: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
|
sanitize_type: 'html',
|
||||||
dependsOn: { key: 'user_verify_allow_apply', value: true },
|
default: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
|
||||||
|
dependsOn: { key: 'user_auth_apply_s', value: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
export const wechatSchema = {
|
|
||||||
key: 'users.wechat',
|
|
||||||
title: '微信通知',
|
|
||||||
icon: 'fa fab fa-weixin',
|
|
||||||
description: '配置微信模板消息、订阅/解绑和站外通知策略。',
|
|
||||||
defaults: {
|
|
||||||
wechat_notify_enabled: false,
|
|
||||||
wechat_app_id: '',
|
|
||||||
wechat_app_secret: '',
|
|
||||||
wechat_template_id: '',
|
|
||||||
wechat_token: '',
|
|
||||||
wechat_encoding_aes_key: '',
|
|
||||||
wechat_notify_comment: true,
|
|
||||||
wechat_notify_reply: true,
|
|
||||||
wechat_notify_system: true,
|
|
||||||
wechat_bind_entry: true,
|
|
||||||
},
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'account',
|
|
||||||
title: '公众号配置',
|
|
||||||
icon: 'fa fab fa-weixin',
|
|
||||||
fields: [
|
|
||||||
{ key: 'wechat_notify_enabled', label: '启用微信通知', type: 'switch', default: false, help: '开启后用户可绑定微信接收通知' },
|
|
||||||
{ key: 'wechat_app_id', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
{ key: 'wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
{ key: 'wechat_template_id', label: '模板消息 ID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在微信公众平台申请的模板消息 ID' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'verify',
|
|
||||||
title: '服务器验证',
|
|
||||||
icon: 'fa fas fa-check-circle',
|
|
||||||
fields: [
|
|
||||||
{ key: 'wechat_token', label: 'Token', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '微信公众平台服务器配置中的 Token' },
|
|
||||||
{ key: 'wechat_encoding_aes_key', label: 'EncodingAESKey', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'triggers',
|
|
||||||
title: '通知触发器',
|
|
||||||
icon: 'fa fas fa-bell',
|
|
||||||
fields: [
|
|
||||||
{ key: 'wechat_notify_comment', label: '新评论通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
{ key: 'wechat_notify_reply', label: '评论回复通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
{ key: 'wechat_notify_system', label: '系统通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
|
|
||||||
{ key: 'wechat_bind_entry', label: '显示绑定入口', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在用户中心显示微信绑定/解绑按钮' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
Loaded 100 of 718 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user