Spaces:
Running
Running
Upload 11 files
Browse files- open_transaction.py +45 -47
- plugin_permissions.py +16 -0
- purchase_transaction.py +2 -0
- router_admin_plugins.py +9 -10
- router_items.py +1 -1
- router_open_api.py +5 -3
- task_read.py +216 -214
- 管理界面/app.js +378 -63
- 管理界面/index.html +79 -79
- 管理界面/style.css +3 -1
open_transaction.py
CHANGED
|
@@ -1,47 +1,45 @@
|
|
| 1 |
-
"""开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
|
| 2 |
-
import hmac
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
from tip_transaction import lock_wallet_accounts
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def validate_live_plugin(row, key, permission):
|
| 12 |
-
if row is None or not row.enabled:
|
| 13 |
-
raise HTTPException(status_code=403, detail="插件已停用或不存在")
|
| 14 |
-
if not isinstance(row.plugin_key, str) or not isinstance(key, str) or not hmac.compare_digest(row.plugin_key.encode('utf-8'), key.encode('utf-8')):
|
| 15 |
-
raise HTTPException(status_code=403, detail="Plugin Key 已失效")
|
| 16 |
-
try:
|
| 17 |
-
permissions =
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
raise HTTPException(status_code=
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
if isinstance(value,
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
raise ValueError("invalid quota history")
|
| 47 |
-
return value
|
|
|
|
| 1 |
+
"""开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
|
| 2 |
+
import hmac
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
from models_sql import RegisteredPlugin
|
| 7 |
+
from plugin_permissions import parse_permissions
|
| 8 |
+
from tip_transaction import lock_wallet_accounts
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def validate_live_plugin(row, key, permission):
|
| 12 |
+
if row is None or not row.enabled:
|
| 13 |
+
raise HTTPException(status_code=403, detail="插件已停用或不存在")
|
| 14 |
+
if not isinstance(row.plugin_key, str) or not isinstance(key, str) or not hmac.compare_digest(row.plugin_key.encode('utf-8'), key.encode('utf-8')):
|
| 15 |
+
raise HTTPException(status_code=403, detail="Plugin Key 已失效")
|
| 16 |
+
try:
|
| 17 |
+
permissions = parse_permissions(row.permissions)
|
| 18 |
+
except Exception:
|
| 19 |
+
raise HTTPException(status_code=503, detail="插件权限配置暂不可用")
|
| 20 |
+
if permission not in permissions:
|
| 21 |
+
raise HTTPException(status_code=403, detail="插件无权限调用该接口")
|
| 22 |
+
return row
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def lock_open_wallet(session, account, plugin_id, key, permission):
|
| 26 |
+
# 首项资金SQL取得SQLite写事务;其它方言先锁钱包再锁插件,所有开放资金路径同序。
|
| 27 |
+
wallet = lock_wallet_accounts(session, (account,))[account]
|
| 28 |
+
row = session.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).populate_existing().with_for_update().first()
|
| 29 |
+
validate_live_plugin(row, key, permission)
|
| 30 |
+
return wallet, row
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def positive_integer(value):
|
| 34 |
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
| 35 |
+
raise HTTPException(status_code=503, detail="插件额度配置暂不可用")
|
| 36 |
+
return value
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def quota_total(value):
|
| 40 |
+
"""SQL聚合可返回整数或精确Decimal;拒绝浮点/字符串/非整数与坏统计。"""
|
| 41 |
+
if isinstance(value, Decimal) and value.is_finite() and value >= 0 and value == value.to_integral_value():
|
| 42 |
+
return int(value)
|
| 43 |
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
| 44 |
+
raise ValueError("invalid quota history")
|
| 45 |
+
return value
|
|
|
|
|
|
plugin_permissions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""插件权限存储的共用解析。"""
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
DEFAULT_PERMISSIONS = ("verify", "balance", "deduct", "refund")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def parse_permissions(value):
|
| 8 |
+
try:
|
| 9 |
+
if not isinstance(value, str):
|
| 10 |
+
raise ValueError("invalid plugin permissions")
|
| 11 |
+
permissions = json.loads(value)
|
| 12 |
+
if not isinstance(permissions, list) or any(not isinstance(item, str) for item in permissions):
|
| 13 |
+
raise ValueError("invalid plugin permissions")
|
| 14 |
+
except (ValueError, TypeError):
|
| 15 |
+
raise ValueError("invalid plugin permissions") from None
|
| 16 |
+
return permissions
|
purchase_transaction.py
CHANGED
|
@@ -12,6 +12,8 @@ def effective_resource_price(item):
|
|
| 12 |
now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
|
| 13 |
if now >= when:
|
| 14 |
price = pending
|
|
|
|
|
|
|
| 15 |
if isinstance(price, bool) or not isinstance(price, int) or price < 0:
|
| 16 |
raise ValueError("invalid resource price")
|
| 17 |
return price
|
|
|
|
| 12 |
now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
|
| 13 |
if now >= when:
|
| 14 |
price = pending
|
| 15 |
+
if type(price) is float and price.is_integer() and 0 <= price <= 2147483647:
|
| 16 |
+
price = int(price)
|
| 17 |
if isinstance(price, bool) or not isinstance(price, int) or price < 0:
|
| 18 |
raise ValueError("invalid resource price")
|
| 19 |
return price
|
router_admin_plugins.py
CHANGED
|
@@ -28,6 +28,7 @@ from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydant
|
|
| 28 |
|
| 29 |
from database_sql import get_db
|
| 30 |
from models_sql import RegisteredPlugin
|
|
|
|
| 31 |
from 安全认证 import require_auth, is_admin, JWT_SECRET
|
| 32 |
|
| 33 |
logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
|
|
@@ -41,10 +42,6 @@ router = APIRouter()
|
|
| 41 |
# plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
|
| 42 |
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
|
| 43 |
|
| 44 |
-
# 默认权限列表
|
| 45 |
-
_DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"]
|
| 46 |
-
|
| 47 |
-
|
| 48 |
def _audit_actor_id(account: str) -> str:
|
| 49 |
"""稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
|
| 50 |
return hmac.new(
|
|
@@ -90,9 +87,11 @@ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: O
|
|
| 90 |
raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
|
| 91 |
"""
|
| 92 |
try:
|
| 93 |
-
permissions =
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
|
| 97 |
data = {
|
| 98 |
"plugin_id": p.plugin_id,
|
|
@@ -103,6 +102,7 @@ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: O
|
|
| 103 |
"max_deduct_per_tx": p.max_deduct_per_tx,
|
| 104 |
"max_deduct_per_day": p.max_deduct_per_day,
|
| 105 |
"permissions": permissions,
|
|
|
|
| 106 |
"created_at": p.created_at.isoformat() if p.created_at else None,
|
| 107 |
"created_by": p.created_by,
|
| 108 |
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
|
@@ -128,7 +128,7 @@ class PluginCreateRequest(BaseModel):
|
|
| 128 |
rate_limit: Optional[int] = Field(100, ge=1, le=10000)
|
| 129 |
max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
|
| 130 |
max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
|
| 131 |
-
permissions: Optional[List[str]] = Field(
|
| 132 |
|
| 133 |
@field_validator("plugin_id")
|
| 134 |
@classmethod
|
|
@@ -137,12 +137,11 @@ class PluginCreateRequest(BaseModel):
|
|
| 137 |
raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
|
| 138 |
return v
|
| 139 |
|
| 140 |
-
# 🔧 pydantic v2:field_validator 默认对默认值也生效,无需 v1 的 always=True
|
| 141 |
@field_validator("permissions")
|
| 142 |
@classmethod
|
| 143 |
def _validate_permissions(cls, v):
|
| 144 |
if v is None:
|
| 145 |
-
return
|
| 146 |
if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
|
| 147 |
raise ValueError("permissions 必须为字符串数组")
|
| 148 |
return v
|
|
|
|
| 28 |
|
| 29 |
from database_sql import get_db
|
| 30 |
from models_sql import RegisteredPlugin
|
| 31 |
+
from plugin_permissions import DEFAULT_PERMISSIONS, parse_permissions
|
| 32 |
from 安全认证 import require_auth, is_admin, JWT_SECRET
|
| 33 |
|
| 34 |
logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
|
|
|
|
| 42 |
# plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
|
| 43 |
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def _audit_actor_id(account: str) -> str:
|
| 46 |
"""稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
|
| 47 |
return hmac.new(
|
|
|
|
| 87 |
raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
|
| 88 |
"""
|
| 89 |
try:
|
| 90 |
+
permissions = parse_permissions(p.permissions)
|
| 91 |
+
permissions_valid = True
|
| 92 |
+
except ValueError:
|
| 93 |
+
permissions = []
|
| 94 |
+
permissions_valid = False
|
| 95 |
|
| 96 |
data = {
|
| 97 |
"plugin_id": p.plugin_id,
|
|
|
|
| 102 |
"max_deduct_per_tx": p.max_deduct_per_tx,
|
| 103 |
"max_deduct_per_day": p.max_deduct_per_day,
|
| 104 |
"permissions": permissions,
|
| 105 |
+
"permissions_valid": permissions_valid,
|
| 106 |
"created_at": p.created_at.isoformat() if p.created_at else None,
|
| 107 |
"created_by": p.created_by,
|
| 108 |
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
|
|
|
| 128 |
rate_limit: Optional[int] = Field(100, ge=1, le=10000)
|
| 129 |
max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
|
| 130 |
max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
|
| 131 |
+
permissions: Optional[List[str]] = Field(default_factory=lambda: list(DEFAULT_PERMISSIONS))
|
| 132 |
|
| 133 |
@field_validator("plugin_id")
|
| 134 |
@classmethod
|
|
|
|
| 137 |
raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
|
| 138 |
return v
|
| 139 |
|
|
|
|
| 140 |
@field_validator("permissions")
|
| 141 |
@classmethod
|
| 142 |
def _validate_permissions(cls, v):
|
| 143 |
if v is None:
|
| 144 |
+
return list(DEFAULT_PERMISSIONS)
|
| 145 |
if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
|
| 146 |
raise ValueError("permissions 必须为字符串数组")
|
| 147 |
return v
|
router_items.py
CHANGED
|
@@ -42,7 +42,7 @@ def _apply_effective_price(item: dict) -> None:
|
|
| 42 |
price = effective_resource_price(item)
|
| 43 |
pending = item.get('pending_price')
|
| 44 |
effective = item.get('pending_price_effective_at')
|
| 45 |
-
if type(pending)
|
| 46 |
when = datetime.datetime.fromisoformat(effective)
|
| 47 |
now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
|
| 48 |
if now >= when:
|
|
|
|
| 42 |
price = effective_resource_price(item)
|
| 43 |
pending = item.get('pending_price')
|
| 44 |
effective = item.get('pending_price_effective_at')
|
| 45 |
+
if type(pending) in (int, float) and pending == price and effective:
|
| 46 |
when = datetime.datetime.fromisoformat(effective)
|
| 47 |
now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
|
| 48 |
if now >= when:
|
router_open_api.py
CHANGED
|
@@ -34,6 +34,7 @@ from pydantic import BaseModel, Field
|
|
| 34 |
from database_sql import get_db, SessionLocal
|
| 35 |
from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
|
| 36 |
from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
|
|
|
|
| 37 |
from tip_store import run_tip_effect
|
| 38 |
from 安全认证 import verify_token_with_fallback
|
| 39 |
import 数据库连接 as json_db
|
|
@@ -87,9 +88,10 @@ def load_plugins_from_db():
|
|
| 87 |
logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
|
| 88 |
continue
|
| 89 |
try:
|
| 90 |
-
perms =
|
| 91 |
-
except
|
| 92 |
-
|
|
|
|
| 93 |
new_index[p.plugin_key] = {
|
| 94 |
"id": p.plugin_id,
|
| 95 |
"key": p.plugin_key,
|
|
|
|
| 34 |
from database_sql import get_db, SessionLocal
|
| 35 |
from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
|
| 36 |
from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
|
| 37 |
+
from plugin_permissions import parse_permissions
|
| 38 |
from tip_store import run_tip_effect
|
| 39 |
from 安全认证 import verify_token_with_fallback
|
| 40 |
import 数据库连接 as json_db
|
|
|
|
| 88 |
logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
|
| 89 |
continue
|
| 90 |
try:
|
| 91 |
+
perms = parse_permissions(p.permissions)
|
| 92 |
+
except ValueError:
|
| 93 |
+
logger.warning("OPEN_API_PLUGIN_PERMISSIONS_INVALID")
|
| 94 |
+
continue
|
| 95 |
new_index[p.plugin_key] = {
|
| 96 |
"id": p.plugin_id,
|
| 97 |
"key": p.plugin_key,
|
task_read.py
CHANGED
|
@@ -1,214 +1,216 @@
|
|
| 1 |
-
"""任务页面只读快照:合并持久状态,不改钱包、JSON或投影确认。"""
|
| 2 |
-
import copy
|
| 3 |
-
import json
|
| 4 |
-
from collections import defaultdict
|
| 5 |
-
from sqlalchemy import func
|
| 6 |
-
from models_sql import (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
|
| 7 |
-
TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement,
|
| 8 |
-
TaskResolution, Transaction)
|
| 9 |
-
from task_terms import validate_patch, funded_amount
|
| 10 |
-
from task_delivery import delivery_patch
|
| 11 |
-
from task_dispute import dispute_payload
|
| 12 |
-
from task_resolution import distribution
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
MODELS = (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
|
| 16 |
-
TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution)
|
| 17 |
-
CLOSING = {'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def ledger(rows, identifier, kind, account, related, amount):
|
| 21 |
-
matches = [r for r in rows if r.tx_id == identifier]
|
| 22 |
-
if (len(matches) != 1 or matches[0].tx_type != kind or matches[0].account != account
|
| 23 |
-
or matches[0].related_account != related or type(matches[0].amount) is not int
|
| 24 |
-
or matches[0].amount != amount):
|
| 25 |
-
raise ValueError('task closing ledger mismatch')
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def merged_task(current, records, funds):
|
| 29 |
-
publication, revision, assignment, delivery, dispute, response, settlement, resolution = records
|
| 30 |
-
if current is None and publication is None:
|
| 31 |
-
raise ValueError('original task missing')
|
| 32 |
-
original = json.loads(publication.payload) if publication is not None else None
|
| 33 |
-
if publication is not None:
|
| 34 |
-
if (not isinstance(original, dict) or original.get('id') != publication.task_id
|
| 35 |
-
or original.get('publisher') != publication.publisher):
|
| 36 |
-
raise ValueError('invalid task publication')
|
| 37 |
-
freezes = [r for r in funds if r.tx_type == 'TASK_FREEZE']
|
| 38 |
-
if (len(freezes) != 1 or freezes[0].account != publication.publisher
|
| 39 |
-
or type(original.get('total_price')) is not int or original['total_price'] < 0
|
| 40 |
-
or type(freezes[0].amount) is not int or freezes[0].amount != -original['total_price']):
|
| 41 |
-
raise ValueError('invalid original task funding')
|
| 42 |
-
result = copy.deepcopy(current if current is not None else original)
|
| 43 |
-
publisher = publication.publisher if publication is not None else result.get('publisher')
|
| 44 |
-
if not isinstance(publisher, str) or not publisher.strip() or result.get('publisher') != publisher:
|
| 45 |
-
raise ValueError('task publisher mismatch')
|
| 46 |
-
identifier = result.get('id')
|
| 47 |
-
for row in records:
|
| 48 |
-
if row is not None and (row.task_id != identifier or row.publisher != publisher):
|
| 49 |
-
raise ValueError('durable task identity mismatch')
|
| 50 |
-
if original is not None:
|
| 51 |
-
for key in ('title', 'description', 'reference_images', 'reference_link', 'total_price',
|
| 52 |
-
'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at'):
|
| 53 |
-
if key in original:
|
| 54 |
-
result[key] = copy.deepcopy(original[key])
|
| 55 |
-
if revision is not None:
|
| 56 |
-
if type(revision.revision) is not int or revision.revision < 1:
|
| 57 |
-
raise ValueError('invalid task revision')
|
| 58 |
-
patch = json.loads(revision.payload)
|
| 59 |
-
validate_patch(patch)
|
| 60 |
-
result.update(copy.deepcopy(patch))
|
| 61 |
-
durable = any(r is not None for r in records)
|
| 62 |
-
amount = funded_amount(funds, publisher) if durable or funds else None
|
| 63 |
-
if amount is not None and (type(result.get('total_price')) is not int or result['total_price'] != amount):
|
| 64 |
-
raise ValueError('task price differs from funding')
|
| 65 |
-
# 持久指派优先于尚未同步的空承接人;已有不同承接人不能被悄悄覆盖。
|
| 66 |
-
participants = [r.assignee for r in (assignment, delivery, dispute, response, settlement, resolution) if r is not None]
|
| 67 |
-
assignee = participants[0] if participants else result.get('assignee')
|
| 68 |
-
if participants and (any(v != assignee for v in participants) or not isinstance(assignee, str)
|
| 69 |
-
or not assignee.strip() or assignee == publisher or result.get('assignee') not in (None, assignee)):
|
| 70 |
-
raise ValueError('task assignee mismatch')
|
| 71 |
-
deposits = [r for r in funds if r.tx_type == 'TASK_DEPOSIT']
|
| 72 |
-
if participants or deposits:
|
| 73 |
-
deposit = result.get('deposit_amount')
|
| 74 |
-
if
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
dispute_id = response.dispute_id
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
result.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
or
|
| 126 |
-
or
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
ledger(closed, resolution.
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
if (
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
"""任务页面只读快照:合并持久状态,不改钱包、JSON或投影确认。"""
|
| 2 |
+
import copy
|
| 3 |
+
import json
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from sqlalchemy import func
|
| 6 |
+
from models_sql import (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
|
| 7 |
+
TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement,
|
| 8 |
+
TaskResolution, Transaction)
|
| 9 |
+
from task_terms import validate_patch, funded_amount
|
| 10 |
+
from task_delivery import delivery_patch
|
| 11 |
+
from task_dispute import dispute_payload
|
| 12 |
+
from task_resolution import distribution
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
MODELS = (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
|
| 16 |
+
TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution)
|
| 17 |
+
CLOSING = {'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def ledger(rows, identifier, kind, account, related, amount):
|
| 21 |
+
matches = [r for r in rows if r.tx_id == identifier]
|
| 22 |
+
if (len(matches) != 1 or matches[0].tx_type != kind or matches[0].account != account
|
| 23 |
+
or matches[0].related_account != related or type(matches[0].amount) is not int
|
| 24 |
+
or matches[0].amount != amount):
|
| 25 |
+
raise ValueError('task closing ledger mismatch')
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def merged_task(current, records, funds):
|
| 29 |
+
publication, revision, assignment, delivery, dispute, response, settlement, resolution = records
|
| 30 |
+
if current is None and publication is None:
|
| 31 |
+
raise ValueError('original task missing')
|
| 32 |
+
original = json.loads(publication.payload) if publication is not None else None
|
| 33 |
+
if publication is not None:
|
| 34 |
+
if (not isinstance(original, dict) or original.get('id') != publication.task_id
|
| 35 |
+
or original.get('publisher') != publication.publisher):
|
| 36 |
+
raise ValueError('invalid task publication')
|
| 37 |
+
freezes = [r for r in funds if r.tx_type == 'TASK_FREEZE']
|
| 38 |
+
if (len(freezes) != 1 or freezes[0].account != publication.publisher
|
| 39 |
+
or type(original.get('total_price')) is not int or original['total_price'] < 0
|
| 40 |
+
or type(freezes[0].amount) is not int or freezes[0].amount != -original['total_price']):
|
| 41 |
+
raise ValueError('invalid original task funding')
|
| 42 |
+
result = copy.deepcopy(current if current is not None else original)
|
| 43 |
+
publisher = publication.publisher if publication is not None else result.get('publisher')
|
| 44 |
+
if not isinstance(publisher, str) or not publisher.strip() or result.get('publisher') != publisher:
|
| 45 |
+
raise ValueError('task publisher mismatch')
|
| 46 |
+
identifier = result.get('id')
|
| 47 |
+
for row in records:
|
| 48 |
+
if row is not None and (row.task_id != identifier or row.publisher != publisher):
|
| 49 |
+
raise ValueError('durable task identity mismatch')
|
| 50 |
+
if original is not None:
|
| 51 |
+
for key in ('title', 'description', 'reference_images', 'reference_link', 'total_price',
|
| 52 |
+
'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at'):
|
| 53 |
+
if key in original:
|
| 54 |
+
result[key] = copy.deepcopy(original[key])
|
| 55 |
+
if revision is not None:
|
| 56 |
+
if type(revision.revision) is not int or revision.revision < 1:
|
| 57 |
+
raise ValueError('invalid task revision')
|
| 58 |
+
patch = json.loads(revision.payload)
|
| 59 |
+
validate_patch(patch)
|
| 60 |
+
result.update(copy.deepcopy(patch))
|
| 61 |
+
durable = any(r is not None for r in records)
|
| 62 |
+
amount = funded_amount(funds, publisher) if durable or funds else None
|
| 63 |
+
if amount is not None and (type(result.get('total_price')) is not int or result['total_price'] != amount):
|
| 64 |
+
raise ValueError('task price differs from funding')
|
| 65 |
+
# 持久指派优先于尚未同步的空承接人;已有不同承接人不能被悄悄覆盖。
|
| 66 |
+
participants = [r.assignee for r in (assignment, delivery, dispute, response, settlement, resolution) if r is not None]
|
| 67 |
+
assignee = participants[0] if participants else result.get('assignee')
|
| 68 |
+
if participants and (any(v != assignee for v in participants) or not isinstance(assignee, str)
|
| 69 |
+
or not assignee.strip() or assignee == publisher or result.get('assignee') not in (None, assignee)):
|
| 70 |
+
raise ValueError('task assignee mismatch')
|
| 71 |
+
deposits = [r for r in funds if r.tx_type == 'TASK_DEPOSIT']
|
| 72 |
+
if participants or deposits:
|
| 73 |
+
deposit = result.get('deposit_amount')
|
| 74 |
+
if type(deposit) is float and deposit.is_integer() and 0 <= deposit <= 2147483647:
|
| 75 |
+
deposit = int(deposit)
|
| 76 |
+
if (len(deposits) != 1 or type(deposit) is not int or amount is None or not 0 <= deposit <= amount
|
| 77 |
+
or deposits[0].account != publisher or deposits[0].related_account != assignee
|
| 78 |
+
or type(deposits[0].amount) is not int or deposits[0].amount != -deposit):
|
| 79 |
+
raise ValueError('task deposit mismatch')
|
| 80 |
+
result.update(assignee=assignee, deposit_amount=deposit, deposit_paid=deposit)
|
| 81 |
+
if assignment is not None:
|
| 82 |
+
if type(assignment.deposit) is not int or assignment.deposit != deposit or type(assignment.assigned_at) is not int:
|
| 83 |
+
raise ValueError('invalid assignment')
|
| 84 |
+
result['assigned_at'] = assignment.assigned_at
|
| 85 |
+
if result.get('status') == 'open':
|
| 86 |
+
result['status'] = 'in_progress'
|
| 87 |
+
if delivery is not None:
|
| 88 |
+
patch = copy.deepcopy(delivery_patch(delivery, result))
|
| 89 |
+
if result.get('status') in {'completed', 'disputed', 'cancelled', 'expired'}:
|
| 90 |
+
patch.pop('status')
|
| 91 |
+
patch.pop('feedback')
|
| 92 |
+
result.update(patch)
|
| 93 |
+
dispute_id = None
|
| 94 |
+
if dispute is not None:
|
| 95 |
+
dispute_payload(dispute) # 核实私有原始记录,不将理由/证据输出至公开任务。
|
| 96 |
+
dispute_id = dispute.dispute_id
|
| 97 |
+
if response is not None:
|
| 98 |
+
payload = json.loads(response.payload)
|
| 99 |
+
from task_dispute_response import response_payload, ORIGINAL_FIELDS
|
| 100 |
+
if not isinstance(payload, dict):
|
| 101 |
+
raise ValueError('invalid dispute response')
|
| 102 |
+
private = dispute_payload(dispute) if dispute is not None else dict(
|
| 103 |
+
id=response.dispute_id, task_id=identifier, publisher=publisher, assignee=assignee,
|
| 104 |
+
**{k: payload.get(k) for k in ORIGINAL_FIELDS})
|
| 105 |
+
response_payload(response, private, result)
|
| 106 |
+
if dispute_id is not None and dispute_id != response.dispute_id:
|
| 107 |
+
raise ValueError('dispute linkage mismatch')
|
| 108 |
+
dispute_id = response.dispute_id
|
| 109 |
+
if dispute_id is not None:
|
| 110 |
+
if result.get('dispute_id') not in (None, dispute_id):
|
| 111 |
+
raise ValueError('task dispute differs from sql')
|
| 112 |
+
result.update(status='disputed', dispute_id=dispute_id)
|
| 113 |
+
closed = [r for r in funds if r.tx_type in CLOSING]
|
| 114 |
+
if settlement is not None:
|
| 115 |
+
if resolution is not None or dispute_id is not None:
|
| 116 |
+
raise ValueError('conflicting task closures')
|
| 117 |
+
if (type(settlement.amount) is not int or settlement.amount != amount
|
| 118 |
+
or type(settlement.deposit) is not int or settlement.deposit != deposit or len(closed) != 2):
|
| 119 |
+
raise ValueError('invalid task settlement')
|
| 120 |
+
ledger(closed, settlement.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
|
| 121 |
+
ledger(closed, settlement.income_tx_id, 'TASK_INCOME', assignee, publisher, amount)
|
| 122 |
+
result.update(status='completed', completed_at=settlement.completed_at, feedback=settlement.feedback)
|
| 123 |
+
elif resolution is not None:
|
| 124 |
+
if (dispute_id is not None and resolution.dispute_id != dispute_id
|
| 125 |
+
or result.get('dispute_id') not in (None, resolution.dispute_id)
|
| 126 |
+
or type(resolution.amount) is not int or resolution.amount != amount
|
| 127 |
+
or type(resolution.deposit) is not int or resolution.deposit != deposit or len(closed) != 3
|
| 128 |
+
or distribution(amount, resolution.initiator_role, resolution.resolution, resolution.ratio)
|
| 129 |
+
!= (resolution.publisher_refund, resolution.assignee_income)):
|
| 130 |
+
raise ValueError('invalid task resolution')
|
| 131 |
+
original_role = dispute_payload(dispute)['initiator_role'] if dispute is not None else (
|
| 132 |
+
json.loads(response.payload)['initiator_role'] if response is not None else resolution.initiator_role)
|
| 133 |
+
if original_role != resolution.initiator_role:
|
| 134 |
+
raise ValueError('resolution role mismatch')
|
| 135 |
+
ledger(closed, resolution.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
|
| 136 |
+
ledger(closed, resolution.refund_tx_id, 'TASK_REFUND', publisher, assignee, resolution.publisher_refund)
|
| 137 |
+
ledger(closed, resolution.income_tx_id, 'TASK_INCOME', assignee, publisher, resolution.assignee_income)
|
| 138 |
+
result.update(status='completed', completed_at=resolution.resolved_at, dispute_id=resolution.dispute_id,
|
| 139 |
+
feedback=f"申诉裁决:{resolution.note or '无'}(结果:{resolution.resolution})")
|
| 140 |
+
elif closed:
|
| 141 |
+
# 开放任务退款无独立状态表,以唯一完整退款流水为事实。
|
| 142 |
+
if len(closed) == 1 and not deposits and not participants and closed[0].tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND'}:
|
| 143 |
+
row = closed[0]
|
| 144 |
+
ledger(closed, row.tx_id, row.tx_type, publisher, None, amount)
|
| 145 |
+
status = 'cancelled' if row.tx_type == 'TASK_CANCEL_REFUND' else 'expired'
|
| 146 |
+
result.update(status=status, refunded=True, refund_amount=amount)
|
| 147 |
+
elif (not durable and result.get('status') == 'completed' and len(closed) == 2
|
| 148 |
+
and len([r for r in closed if r.tx_type == 'TASK_PAYMENT']) == 1
|
| 149 |
+
and len([r for r in closed if r.tx_type == 'TASK_INCOME']) == 1):
|
| 150 |
+
# 旧已完成任务可以按有效双流水读取,不生成猜测的完成时间或反馈。
|
| 151 |
+
payment = next(r for r in closed if r.tx_type == 'TASK_PAYMENT')
|
| 152 |
+
income = next(r for r in closed if r.tx_type == 'TASK_INCOME')
|
| 153 |
+
ledger(closed, payment.tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
|
| 154 |
+
ledger(closed, income.tx_id, 'TASK_INCOME', assignee, publisher, amount)
|
| 155 |
+
else:
|
| 156 |
+
raise ValueError('closing task lacks matching durable state')
|
| 157 |
+
elif durable and result.get('status') in {'completed', 'cancelled', 'expired'}:
|
| 158 |
+
raise ValueError('closed task lacks closing funds')
|
| 159 |
+
applicants = result.get('applicants', [])
|
| 160 |
+
if result.get('status') not in {'open', 'in_progress', 'submitted', 'disputed', 'completed', 'cancelled', 'expired'}:
|
| 161 |
+
raise ValueError('invalid task status')
|
| 162 |
+
if (not isinstance(applicants, list) or any(not isinstance(a, dict) or not isinstance(a.get('account'), str)
|
| 163 |
+
or not a['account'].strip() for a in applicants)
|
| 164 |
+
or len({a['account'] for a in applicants}) != len(applicants)):
|
| 165 |
+
raise ValueError('invalid task applicants')
|
| 166 |
+
return result
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def merge_task_source(session, source, task_id=None):
|
| 170 |
+
"""合并调用方已经严格读取的快照;不保存或复用跨轮次缓存。"""
|
| 171 |
+
if not isinstance(source, list):
|
| 172 |
+
raise ValueError('invalid task source')
|
| 173 |
+
current = {}
|
| 174 |
+
for task in source:
|
| 175 |
+
if not isinstance(task, dict) or not isinstance(task.get('id'), str) or not task['id'] or task['id'] in current:
|
| 176 |
+
raise ValueError('invalid or duplicate task')
|
| 177 |
+
current[task['id']] = task
|
| 178 |
+
identifiers = set(current) if task_id is None else ({task_id} if task_id in current else set())
|
| 179 |
+
maps = []
|
| 180 |
+
for model in MODELS:
|
| 181 |
+
query = session.query(model)
|
| 182 |
+
if model is TaskRevision:
|
| 183 |
+
latest = session.query(TaskRevision.task_id, func.max(TaskRevision.revision).label('version'))
|
| 184 |
+
if task_id is not None:
|
| 185 |
+
latest = latest.filter(TaskRevision.task_id == task_id)
|
| 186 |
+
latest = latest.group_by(TaskRevision.task_id).subquery()
|
| 187 |
+
query = query.join(latest, (TaskRevision.task_id == latest.c.task_id)
|
| 188 |
+
& (TaskRevision.revision == latest.c.version))
|
| 189 |
+
if task_id is not None:
|
| 190 |
+
query = query.filter(model.task_id == task_id)
|
| 191 |
+
rows = query.populate_existing().all()
|
| 192 |
+
records = {}
|
| 193 |
+
for row in rows:
|
| 194 |
+
if model is TaskRevision:
|
| 195 |
+
prior = records.get(row.task_id)
|
| 196 |
+
if prior is not None and prior.revision > row.revision:
|
| 197 |
+
continue
|
| 198 |
+
elif row.task_id in records:
|
| 199 |
+
raise ValueError('duplicate durable task state')
|
| 200 |
+
records[row.task_id] = row
|
| 201 |
+
identifiers.add(row.task_id)
|
| 202 |
+
maps.append(records)
|
| 203 |
+
query = session.query(Transaction).filter(Transaction.tx_type.in_(
|
| 204 |
+
['TASK_FREEZE', 'TASK_PRICE_ADJUST', 'TASK_DEPOSIT', *CLOSING]))
|
| 205 |
+
if task_id is not None:
|
| 206 |
+
query = query.filter(Transaction.item_id == task_id)
|
| 207 |
+
funds = defaultdict(list)
|
| 208 |
+
for row in query.populate_existing().all():
|
| 209 |
+
funds[row.item_id].append(row)
|
| 210 |
+
return [merged_task(current.get(identifier), tuple(m.get(identifier) for m in maps), funds[identifier])
|
| 211 |
+
for identifier in sorted(identifiers)]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def read_tasks(session, store, task_id=None):
|
| 215 |
+
source = store.load_data('tasks.json', default_data=[], strict=True, skip_cache=True)
|
| 216 |
+
return merge_task_source(session, source, task_id)
|
管理界面/app.js
CHANGED
|
@@ -9,6 +9,84 @@
|
|
| 9 |
// 新增反馈集中在双语词典;管理页语言遵循文档lang。
|
| 10 |
const ADMIN_MESSAGES = {
|
| 11 |
zh: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
broadcast_subtitle: '发布全站系统公告,管理顶部广告横幅展示。',
|
| 13 |
announcement_title: '📢 全站系统公告', announcement_content: '公告内容',
|
| 14 |
announcement_placeholder: '输入要向全体用户推送的公告内容…', announcement_publish: '🚀 确认发布',
|
|
@@ -165,6 +243,8 @@
|
|
| 165 |
config_read_unavailable: '无法确认当前配置,请稍后重新读取。',
|
| 166 |
session_changed: '登录状态已变化,请在当前会话重新操作。',
|
| 167 |
network_unavailable: '网络异常,请检查连接后重试。',
|
|
|
|
|
|
|
| 168 |
auth_expired: '登录已失效,请重新登录。',
|
| 169 |
admin_verification_unavailable: '无法确认管理员权限,请稍后重新登录。',
|
| 170 |
admin_data_unavailable: '无法确认返回的数据,请稍后重新读取。',
|
|
@@ -172,6 +252,84 @@
|
|
| 172 |
banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
|
| 173 |
},
|
| 174 |
en: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
broadcast_subtitle: 'Publish announcements for all users and manage the top advertisement banner.',
|
| 176 |
announcement_title: '📢 Site announcements', announcement_content: 'Announcement content',
|
| 177 |
announcement_placeholder: 'Enter an announcement for all users…', announcement_publish: '🚀 Publish announcement',
|
|
@@ -328,6 +486,8 @@
|
|
| 328 |
config_read_unavailable: 'The current configuration could not be confirmed. Reload it later.',
|
| 329 |
session_changed: 'The session has changed. Continue in the current session.',
|
| 330 |
network_unavailable: 'Network unavailable. Check your connection before retrying.',
|
|
|
|
|
|
|
| 331 |
auth_expired: 'Your session has expired. Sign in again.',
|
| 332 |
admin_verification_unavailable: 'Administrator access could not be confirmed. Sign in again later.',
|
| 333 |
admin_data_unavailable: 'The returned data could not be confirmed. Reload it later.',
|
|
@@ -347,10 +507,10 @@
|
|
| 347 |
const USER_KEY = 'ranking_admin_user';
|
| 348 |
const ALL_PERMS = ['verify', 'balance', 'deduct', 'refund'];
|
| 349 |
const PERM_LABELS = {
|
| 350 |
-
verify: '
|
| 351 |
-
balance: '
|
| 352 |
-
deduct: '
|
| 353 |
-
refund: '
|
| 354 |
};
|
| 355 |
|
| 356 |
// -------------------- 状态 --------------------
|
|
@@ -769,6 +929,10 @@
|
|
| 769 |
|
| 770 |
// -------------------- API 客户端 --------------------
|
| 771 |
let adminSessionGeneration = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
const adminRequests = new Set();
|
| 773 |
function revokeAdminRequests() {
|
| 774 |
for (const controller of Array.from(adminRequests)) {
|
|
@@ -786,18 +950,72 @@
|
|
| 786 |
throw error;
|
| 787 |
}
|
| 788 |
}
|
| 789 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
const session = captureAdminSession();
|
| 791 |
const controller = new AbortController();
|
| 792 |
const headers = { Accept: 'application/json', ...options.headers };
|
| 793 |
if (session.token) headers.Authorization = 'Bearer ' + session.token;
|
| 794 |
adminRequests.add(controller);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 795 |
try {
|
| 796 |
let response;
|
| 797 |
try {
|
| 798 |
-
response = await fetch(API_BASE + path, { ...options, headers, signal: controller.signal });
|
| 799 |
} catch (err) {
|
| 800 |
assertAdminSession(session);
|
|
|
|
| 801 |
throw new ApiError(adminMessage('network_unavailable'), 0);
|
| 802 |
}
|
| 803 |
assertAdminSession(session);
|
|
@@ -807,9 +1025,11 @@
|
|
| 807 |
}
|
| 808 |
let text;
|
| 809 |
try {
|
| 810 |
-
text = await
|
| 811 |
} catch (err) {
|
|
|
|
| 812 |
assertAdminSession(session);
|
|
|
|
| 813 |
throw new ApiError(adminMessage('network_unavailable'), 0);
|
| 814 |
}
|
| 815 |
assertAdminSession(session);
|
|
@@ -828,6 +1048,7 @@
|
|
| 828 |
}
|
| 829 |
return payload;
|
| 830 |
} finally {
|
|
|
|
| 831 |
adminRequests.delete(controller);
|
| 832 |
}
|
| 833 |
}
|
|
@@ -852,7 +1073,7 @@
|
|
| 852 |
|
| 853 |
// 🆕 multipart 上传(广告图片):FormData 不序列化,复用同一会话和取消边界。
|
| 854 |
async function apiUpload(path, formData) {
|
| 855 |
-
return performAdminRequest(path, { method: 'POST', body: formData });
|
| 856 |
}
|
| 857 |
|
| 858 |
// -------------------- 视图切换 --------------------
|
|
@@ -1008,11 +1229,16 @@
|
|
| 1008 |
}
|
| 1009 |
|
| 1010 |
// -------------------- 插件列表 --------------------
|
|
|
|
|
|
|
|
|
|
| 1011 |
function setListState(stateName, message) {
|
|
|
|
| 1012 |
$('#list-loading').hidden = stateName !== 'loading';
|
| 1013 |
$('#list-empty').hidden = stateName !== 'empty';
|
| 1014 |
$('#list-error').hidden = stateName !== 'error';
|
| 1015 |
$('#plugin-grid').hidden = stateName !== 'data';
|
|
|
|
| 1016 |
if (stateName === 'error' && message) {
|
| 1017 |
$('#list-error-msg').textContent = message;
|
| 1018 |
}
|
|
@@ -1071,6 +1297,8 @@
|
|
| 1071 |
}
|
| 1072 |
|
| 1073 |
function renderList() {
|
|
|
|
|
|
|
| 1074 |
const grid = $('#plugin-grid');
|
| 1075 |
const tpl = $('#tpl-plugin-card');
|
| 1076 |
if (!grid || !tpl) return;
|
|
@@ -1083,14 +1311,16 @@
|
|
| 1083 |
grid.innerHTML = '';
|
| 1084 |
|
| 1085 |
if (!state.filtered.length) {
|
| 1086 |
-
grid.innerHTML = '<div class="state-block" style="grid-column: 1/-1;">' +
|
| 1087 |
-
'<div class="empty-mark">∅</div><h3>
|
|
|
|
| 1088 |
return;
|
| 1089 |
}
|
| 1090 |
|
| 1091 |
const frag = document.createDocumentFragment();
|
| 1092 |
state.filtered.forEach((plugin) => {
|
| 1093 |
const node = tpl.content.firstElementChild.cloneNode(true);
|
|
|
|
| 1094 |
const id = plugin.plugin_id || plugin.id || '';
|
| 1095 |
const name = plugin.name || id;
|
| 1096 |
|
|
@@ -1101,10 +1331,10 @@
|
|
| 1101 |
|
| 1102 |
const badge = $('.js-status', node);
|
| 1103 |
if (plugin.enabled) {
|
| 1104 |
-
badge.textContent = '
|
| 1105 |
badge.classList.add('is-on');
|
| 1106 |
} else {
|
| 1107 |
-
badge.textContent = '
|
| 1108 |
badge.classList.add('is-off');
|
| 1109 |
}
|
| 1110 |
|
|
@@ -1114,19 +1344,25 @@
|
|
| 1114 |
$('.js-created', node).textContent = formatDate(plugin.created_at || plugin.create_time || plugin.createdAt);
|
| 1115 |
|
| 1116 |
const permsEl = $('.js-perms', node);
|
| 1117 |
-
const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
|
| 1118 |
-
if (
|
| 1119 |
-
permsEl.
|
|
|
|
|
|
|
| 1120 |
} else {
|
| 1121 |
-
permsEl.textContent = '
|
| 1122 |
}
|
| 1123 |
|
| 1124 |
const toggleBtn = $('.js-toggle', node);
|
| 1125 |
-
toggleBtn.textContent = plugin.enabled ? '
|
|
|
|
|
|
|
| 1126 |
|
| 1127 |
// 操作绑定
|
| 1128 |
$$('button[data-act]', node).forEach((btn) => {
|
| 1129 |
-
btn.
|
|
|
|
|
|
|
| 1130 |
});
|
| 1131 |
|
| 1132 |
frag.appendChild(node);
|
|
@@ -1134,12 +1370,12 @@
|
|
| 1134 |
grid.appendChild(frag);
|
| 1135 |
}
|
| 1136 |
|
| 1137 |
-
function onCardAction(act, plugin, btn) {
|
| 1138 |
-
|
| 1139 |
-
if (act === 'edit') return openEditModal(plugin);
|
| 1140 |
-
if (act === 'rotate') return rotateKey(plugin, btn);
|
| 1141 |
-
if (act === 'toggle') return togglePlugin(plugin, btn);
|
| 1142 |
-
if (act === 'delete') return deletePlugin(plugin, btn);
|
| 1143 |
}
|
| 1144 |
|
| 1145 |
// -------------------- 模态框 --------------------
|
|
@@ -1151,7 +1387,11 @@
|
|
| 1151 |
adminModalFocus().open(el);
|
| 1152 |
}
|
| 1153 |
function closeModal(id) {
|
| 1154 |
-
if (id === 'modal-key')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1155 |
if (id === 'modal-confirm') state.confirmHandler = null;
|
| 1156 |
if (id === 'modal-dispute') adminReadOperations.delete('dispute-detail');
|
| 1157 |
const el = document.getElementById(id) || adminModalFocusController?.findMask(id);
|
|
@@ -1209,22 +1449,29 @@
|
|
| 1209 |
const fd = new FormData(form);
|
| 1210 |
const pluginId = String(fd.get('plugin_id') || '').trim();
|
| 1211 |
const name = String(fd.get('name') || '').trim();
|
| 1212 |
-
if (!/^[
|
| 1213 |
-
showError(errBox, '
|
|
|
|
| 1214 |
return;
|
| 1215 |
}
|
| 1216 |
if (!name) {
|
| 1217 |
-
showError(errBox, '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1218 |
return;
|
| 1219 |
}
|
| 1220 |
|
| 1221 |
const payload = {
|
| 1222 |
plugin_id: pluginId,
|
| 1223 |
name: name,
|
| 1224 |
-
|
| 1225 |
-
rate_limit: toIntOr(fd.get('rate_limit'), 100),
|
| 1226 |
-
max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
|
| 1227 |
-
max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
|
| 1228 |
permissions: fd.getAll('permissions'),
|
| 1229 |
};
|
| 1230 |
|
|
@@ -1238,24 +1485,56 @@
|
|
| 1238 |
modal.assertCurrent();
|
| 1239 |
const result = confirmAdminPluginWrite(data, pluginId, 'create');
|
| 1240 |
closeModal('modal-create');
|
| 1241 |
-
showKeyModal(result.plugin_key, '
|
| 1242 |
await loadPlugins();
|
| 1243 |
} catch (err) {
|
| 1244 |
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
|
| 1245 |
-
showError(errBox, err.message || '
|
| 1246 |
} finally {
|
| 1247 |
finishBusy();
|
| 1248 |
}
|
| 1249 |
});
|
| 1250 |
}
|
| 1251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
function toIntOr(value, fallback) {
|
| 1253 |
const num = parseInt(value, 10);
|
| 1254 |
return isNaN(num) ? fallback : num;
|
| 1255 |
}
|
| 1256 |
|
| 1257 |
// -------------------- 编辑插件 --------------------
|
| 1258 |
-
function openEditModal(plugin) {
|
|
|
|
|
|
|
|
|
|
| 1259 |
const form = $('#form-edit');
|
| 1260 |
form.reset();
|
| 1261 |
showError($('#form-edit-error'), '');
|
|
@@ -1270,11 +1549,14 @@
|
|
| 1270 |
form.querySelector('[name="max_deduct_per_tx"]').value = plugin.max_deduct_per_tx ?? 100;
|
| 1271 |
form.querySelector('[name="max_deduct_per_day"]').value = plugin.max_deduct_per_day ?? 1000;
|
| 1272 |
|
| 1273 |
-
const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
|
| 1274 |
$$('#form-edit input[name="permissions"]').forEach((cb) => {
|
| 1275 |
cb.checked = perms.includes(cb.value);
|
| 1276 |
});
|
| 1277 |
|
|
|
|
|
|
|
|
|
|
| 1278 |
$('#edit-key-display').textContent = plugin.plugin_key || maskKey(id);
|
| 1279 |
|
| 1280 |
openModal('modal-edit');
|
|
@@ -1284,22 +1566,29 @@
|
|
| 1284 |
const form = $('#form-edit');
|
| 1285 |
form.addEventListener('submit', async (event) => {
|
| 1286 |
event.preventDefault();
|
|
|
|
|
|
|
| 1287 |
const errBox = $('#form-edit-error');
|
| 1288 |
showError(errBox, '');
|
| 1289 |
|
| 1290 |
const id = form.dataset.pluginId;
|
| 1291 |
-
if (!id) { showError(errBox, '
|
| 1292 |
|
| 1293 |
const fd = new FormData(form);
|
| 1294 |
const name = String(fd.get('name') || '').trim();
|
| 1295 |
-
if (!name) { showError(errBox, '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
|
| 1297 |
const payload = {
|
| 1298 |
name: name,
|
| 1299 |
-
|
| 1300 |
-
rate_limit: toIntOr(fd.get('rate_limit'), 100),
|
| 1301 |
-
max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
|
| 1302 |
-
max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
|
| 1303 |
permissions: fd.getAll('permissions'),
|
| 1304 |
};
|
| 1305 |
|
|
@@ -1311,13 +1600,14 @@
|
|
| 1311 |
const response = await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
|
| 1312 |
finishBusy.assertCurrent();
|
| 1313 |
modal.assertCurrent();
|
|
|
|
| 1314 |
confirmAdminPluginWrite(response, id, 'edit');
|
| 1315 |
closeModal('modal-edit');
|
| 1316 |
-
toast('
|
| 1317 |
await loadPlugins();
|
| 1318 |
} catch (err) {
|
| 1319 |
-
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
|
| 1320 |
-
showError(errBox, err.message || '
|
| 1321 |
} finally {
|
| 1322 |
finishBusy();
|
| 1323 |
}
|
|
@@ -1325,53 +1615,67 @@
|
|
| 1325 |
}
|
| 1326 |
|
| 1327 |
// -------------------- 轮换 / 切换 / 删除 --------------------
|
| 1328 |
-
function rotateKey(plugin, btn) {
|
|
|
|
|
|
|
|
|
|
| 1329 |
const id = plugin.plugin_id || plugin.id;
|
| 1330 |
confirmAction({
|
| 1331 |
-
title: '
|
| 1332 |
-
message:
|
| 1333 |
-
okText: '
|
| 1334 |
danger: false,
|
| 1335 |
onConfirm: async (assertCurrent) => {
|
|
|
|
|
|
|
| 1336 |
const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
|
| 1337 |
assertCurrent();
|
|
|
|
| 1338 |
const result = confirmAdminPluginWrite(data, id, 'rotate');
|
| 1339 |
-
showKeyModal(result.plugin_key, '
|
| 1340 |
await loadPlugins();
|
| 1341 |
},
|
| 1342 |
});
|
| 1343 |
}
|
| 1344 |
|
| 1345 |
-
function togglePlugin(plugin, btn) {
|
|
|
|
| 1346 |
const id = plugin.plugin_id || plugin.id;
|
| 1347 |
const finishBusy = startAdminBusy(btn);
|
| 1348 |
if (!finishBusy) return;
|
| 1349 |
apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
|
| 1350 |
.then(async (response) => {
|
| 1351 |
finishBusy.assertCurrent();
|
|
|
|
| 1352 |
const result = confirmAdminPluginWrite(response, id, 'toggle');
|
| 1353 |
-
toast(result.enabled ? '
|
| 1354 |
await loadPlugins();
|
| 1355 |
})
|
| 1356 |
.catch((err) => {
|
| 1357 |
-
if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
|
| 1358 |
-
toast(err.message || '
|
| 1359 |
})
|
| 1360 |
.finally(() => finishBusy());
|
| 1361 |
}
|
| 1362 |
|
| 1363 |
-
function deletePlugin(plugin, btn) {
|
|
|
|
|
|
|
|
|
|
| 1364 |
const id = plugin.plugin_id || plugin.id;
|
| 1365 |
confirmAction({
|
| 1366 |
-
title: '
|
| 1367 |
-
message:
|
| 1368 |
-
okText: '
|
| 1369 |
danger: true,
|
| 1370 |
onConfirm: async (assertCurrent) => {
|
|
|
|
|
|
|
| 1371 |
const response = await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
|
| 1372 |
assertCurrent();
|
|
|
|
| 1373 |
confirmAdminPluginWrite(response, id, 'delete');
|
| 1374 |
-
toast('
|
| 1375 |
await loadPlugins();
|
| 1376 |
},
|
| 1377 |
});
|
|
@@ -2462,7 +2766,18 @@
|
|
| 2462 |
}
|
| 2463 |
|
| 2464 |
// -------------------- 主视图绑定 --------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2465 |
function bindMain() {
|
|
|
|
| 2466 |
$('#btn-logout').addEventListener('click', () => {
|
| 2467 |
confirmAction({
|
| 2468 |
title: adminMessage('logout_title'),
|
|
@@ -2484,12 +2799,12 @@
|
|
| 2484 |
try {
|
| 2485 |
await loadPlugins();
|
| 2486 |
finishBusy.assertCurrent();
|
| 2487 |
-
toast('
|
| 2488 |
} catch (err) {
|
| 2489 |
if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
|
| 2490 |
if (!(err instanceof ApiError && err.status === 401)) {
|
| 2491 |
-
setListState('error', err.message || '
|
| 2492 |
-
toast(err.message || '
|
| 2493 |
}
|
| 2494 |
} finally {
|
| 2495 |
finishBusy();
|
|
@@ -2501,7 +2816,7 @@
|
|
| 2501 |
catch (err) {
|
| 2502 |
if (isStaleSessionError(err)) return;
|
| 2503 |
if (!(err instanceof ApiError && err.status === 401)) {
|
| 2504 |
-
setListState('error', err.message || '
|
| 2505 |
}
|
| 2506 |
}
|
| 2507 |
});
|
|
|
|
| 9 |
// 新增反馈集中在双语词典;管理页语言遵循文档lang。
|
| 10 |
const ADMIN_MESSAGES = {
|
| 11 |
zh: {
|
| 12 |
+
plugin_edit_title: "编辑插件",
|
| 13 |
+
plugin_id_hint: "用于标识插件,创建后不可修改。",
|
| 14 |
+
plugin_id_placeholder: "小写英文/数字/下划线,3-50 字符",
|
| 15 |
+
plugin_name: "名称",
|
| 16 |
+
plugin_name_placeholder: "例如:示例插件",
|
| 17 |
+
plugin_description: "描述",
|
| 18 |
+
plugin_description_placeholder: "对该插件的简要说明(可选)",
|
| 19 |
+
plugin_rate_label: "每分钟限流",
|
| 20 |
+
plugin_tx_label: "单笔扣款上限",
|
| 21 |
+
plugin_day_label: "日扣款上限",
|
| 22 |
+
plugin_cancel: "取消",
|
| 23 |
+
plugin_create_submit: "创建插件",
|
| 24 |
+
plugin_edit_submit: "保存修改",
|
| 25 |
+
plugin_id_fixed: "不可修改。",
|
| 26 |
+
plugin_key_hidden_hint: "完整 Key 已隐藏。如需更换请使用「轮换 Key」。",
|
| 27 |
+
plugin_key_title: "插件 Key 已生成",
|
| 28 |
+
plugin_key_save_title: "请妥善保存此 Key",
|
| 29 |
+
plugin_key_save_hint: "关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。",
|
| 30 |
+
plugin_key_rotated_hint: "新 Key 已生成,请妥善保存。旧 Key 已失效。",
|
| 31 |
+
plugin_key_done: "我已保存",
|
| 32 |
+
plugin_id_invalid: "plugin_id 仅允许小写英文、数字和下划线,长度 3-50。",
|
| 33 |
+
plugin_name_required: "请填写插件名称。",
|
| 34 |
+
plugin_id_missing: "插件标识缺失",
|
| 35 |
+
plugin_name_long: "插件名称不能超过100个字符。",
|
| 36 |
+
plugin_description_long: "插件描述不能超过500个字符。",
|
| 37 |
+
plugin_create_failed: "创建失败",
|
| 38 |
+
plugin_edit_failed: "保存失败",
|
| 39 |
+
plugin_saved: "修改已保存",
|
| 40 |
+
plugin_limit_invalid: "{field}必须是1至{max}之间的整数。",
|
| 41 |
+
plugin_rotate_message: "轮换后旧 Key 立即失效,确定继续?\n插件:{plugin}",
|
| 42 |
+
plugin_rotate_confirm: "确认轮换",
|
| 43 |
+
plugin_deleted: "插件已删除",
|
| 44 |
+
plugin_delete_message: "确定删除插件「{plugin}」?此操作不可逆。",
|
| 45 |
+
plugin_delete_confirm: "确认删除",
|
| 46 |
+
plugin_now_enabled: "插件已启用",
|
| 47 |
+
plugin_now_disabled: "插件已禁用",
|
| 48 |
+
plugin_toggle_failed: "切换状态失败",
|
| 49 |
+
plugin_description_empty: "暂无描述",
|
| 50 |
+
plugin_title: "第三方插件",
|
| 51 |
+
plugin_subtitle: "管理 plugin_key、限流配额、权限与启用状态。",
|
| 52 |
+
plugin_console: "控制台",
|
| 53 |
+
plugin_location: "位置",
|
| 54 |
+
plugin_search: "搜索插件",
|
| 55 |
+
plugin_search_placeholder: "搜索 plugin_id 或名称…",
|
| 56 |
+
plugin_refresh: "刷新",
|
| 57 |
+
plugin_create: "创建新插件",
|
| 58 |
+
plugin_total: "插件总数",
|
| 59 |
+
plugin_enabled: "已启用",
|
| 60 |
+
plugin_disabled: "已禁用",
|
| 61 |
+
plugin_loading: "正在加载插件列表…",
|
| 62 |
+
plugin_empty: "暂无插件",
|
| 63 |
+
plugin_empty_hint: "点击「创建新插件」开始接入第三方应用。",
|
| 64 |
+
plugin_load_failed: "加载失败",
|
| 65 |
+
plugin_retry_hint: "请稍后重试。",
|
| 66 |
+
plugin_retry: "重新加载",
|
| 67 |
+
plugin_no_match: "未匹配到插件",
|
| 68 |
+
plugin_no_match_hint: "尝试调整搜索关键字。",
|
| 69 |
+
plugin_rate: "限流",
|
| 70 |
+
plugin_rate_unit: "次/分钟",
|
| 71 |
+
plugin_tx_limit: "单笔限额",
|
| 72 |
+
plugin_day_limit: "日限额",
|
| 73 |
+
plugin_permissions: "权限",
|
| 74 |
+
plugin_permissions_unavailable: "权限配置不可用",
|
| 75 |
+
plugin_permissions_none: "未授予权限",
|
| 76 |
+
plugin_permissions_review: "权限配置无法读取,请重新确认权限后保存。",
|
| 77 |
+
plugin_created: "创建时间",
|
| 78 |
+
plugin_edit: "编辑",
|
| 79 |
+
plugin_rotate: "轮换 Key",
|
| 80 |
+
plugin_enable: "启用",
|
| 81 |
+
plugin_disable: "禁用",
|
| 82 |
+
plugin_delete: "删除",
|
| 83 |
+
plugin_active: "启用中",
|
| 84 |
+
plugin_refreshed: "已刷新",
|
| 85 |
+
plugin_perm_verify: "验证 Key",
|
| 86 |
+
plugin_perm_balance: "查询余额",
|
| 87 |
+
plugin_perm_deduct: "扣除积分",
|
| 88 |
+
plugin_perm_refund: "退款回滚",
|
| 89 |
+
plugin_action_name: "{action}:{plugin}",
|
| 90 |
broadcast_subtitle: '发布全站系统公告,管理顶部广告横幅展示。',
|
| 91 |
announcement_title: '📢 全站系统公告', announcement_content: '公告内容',
|
| 92 |
announcement_placeholder: '输入要向全体用户推送的公告内容…', announcement_publish: '🚀 确认发布',
|
|
|
|
| 243 |
config_read_unavailable: '无法确认当前配置,请稍后重新读取。',
|
| 244 |
session_changed: '登录状态已变化,请在当前会话重新操作。',
|
| 245 |
network_unavailable: '网络异常,请检查连接后重试。',
|
| 246 |
+
request_timed_out: '请求超时,结果暂未确认。请先检查当前状态,再决定是否重试。',
|
| 247 |
+
response_unavailable: '无法确认服务器响应。请先核实当前状态,再决定是否重试。',
|
| 248 |
auth_expired: '登录已失效,请重新登录。',
|
| 249 |
admin_verification_unavailable: '无法确认管理员权限,请稍后重新登录。',
|
| 250 |
admin_data_unavailable: '无法确认返回的数据,请稍后重新读取。',
|
|
|
|
| 252 |
banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
|
| 253 |
},
|
| 254 |
en: {
|
| 255 |
+
plugin_edit_title: "Edit plugin",
|
| 256 |
+
plugin_id_hint: "Identifies the plugin and cannot be changed after creation.",
|
| 257 |
+
plugin_id_placeholder: "Lowercase letters, digits or _, 3–50 chars",
|
| 258 |
+
plugin_name: "Name",
|
| 259 |
+
plugin_name_placeholder: "For example: Sample plugin",
|
| 260 |
+
plugin_description: "Description",
|
| 261 |
+
plugin_description_placeholder: "Brief plugin description (optional)",
|
| 262 |
+
plugin_rate_label: "Requests per minute",
|
| 263 |
+
plugin_tx_label: "Maximum credits per transaction",
|
| 264 |
+
plugin_day_label: "Maximum credits per day",
|
| 265 |
+
plugin_cancel: "Cancel",
|
| 266 |
+
plugin_create_submit: "Create plugin",
|
| 267 |
+
plugin_edit_submit: "Save changes",
|
| 268 |
+
plugin_id_fixed: "Cannot be changed.",
|
| 269 |
+
plugin_key_hidden_hint: "The full key is hidden. Use “Rotate key” to replace it.",
|
| 270 |
+
plugin_key_title: "Plugin key generated",
|
| 271 |
+
plugin_key_save_title: "Keep this key safe",
|
| 272 |
+
plugin_key_save_hint: "You cannot view the full key again after closing this dialog. If lost, use “Rotate key” to generate a new credential.",
|
| 273 |
+
plugin_key_rotated_hint: "A new key has been generated. Keep it safe. The previous key is no longer valid.",
|
| 274 |
+
plugin_key_done: "I have saved it",
|
| 275 |
+
plugin_id_invalid: "Plugin ID must contain 3–50 lowercase letters, digits or underscores.",
|
| 276 |
+
plugin_name_required: "Enter a plugin name.",
|
| 277 |
+
plugin_id_missing: "Plugin ID is missing.",
|
| 278 |
+
plugin_name_long: "Plugin name must not exceed 100 characters.",
|
| 279 |
+
plugin_description_long: "Plugin description must not exceed 500 characters.",
|
| 280 |
+
plugin_create_failed: "Unable to create plugin.",
|
| 281 |
+
plugin_edit_failed: "Unable to save plugin.",
|
| 282 |
+
plugin_saved: "Changes saved.",
|
| 283 |
+
plugin_limit_invalid: "{field} must be a whole number from 1 to {max}.",
|
| 284 |
+
plugin_rotate_message: "The previous key will stop working immediately. Continue?\nPlugin: {plugin}",
|
| 285 |
+
plugin_rotate_confirm: "Rotate key",
|
| 286 |
+
plugin_deleted: "Plugin deleted.",
|
| 287 |
+
plugin_delete_message: "Delete plugin “{plugin}”? This cannot be undone.",
|
| 288 |
+
plugin_delete_confirm: "Delete plugin",
|
| 289 |
+
plugin_now_enabled: "Plugin enabled.",
|
| 290 |
+
plugin_now_disabled: "Plugin disabled.",
|
| 291 |
+
plugin_toggle_failed: "Unable to change plugin status.",
|
| 292 |
+
plugin_description_empty: "No description",
|
| 293 |
+
plugin_title: "Third-party plugins",
|
| 294 |
+
plugin_subtitle: "Manage plugin keys, quotas, permissions and availability.",
|
| 295 |
+
plugin_console: "Console",
|
| 296 |
+
plugin_location: "Location",
|
| 297 |
+
plugin_search: "Search plugins",
|
| 298 |
+
plugin_search_placeholder: "Search plugin ID or name…",
|
| 299 |
+
plugin_refresh: "Refresh",
|
| 300 |
+
plugin_create: "Create plugin",
|
| 301 |
+
plugin_total: "Total plugins",
|
| 302 |
+
plugin_enabled: "Enabled",
|
| 303 |
+
plugin_disabled: "Disabled",
|
| 304 |
+
plugin_loading: "Loading plugins…",
|
| 305 |
+
plugin_empty: "No plugins yet",
|
| 306 |
+
plugin_empty_hint: "Select “Create plugin” to connect a third-party app.",
|
| 307 |
+
plugin_load_failed: "Unable to load plugins",
|
| 308 |
+
plugin_retry_hint: "Please try again later.",
|
| 309 |
+
plugin_retry: "Try again",
|
| 310 |
+
plugin_no_match: "No matching plugins",
|
| 311 |
+
plugin_no_match_hint: "Try a different search.",
|
| 312 |
+
plugin_rate: "Rate limit",
|
| 313 |
+
plugin_rate_unit: "requests/min",
|
| 314 |
+
plugin_tx_limit: "Per-transaction limit",
|
| 315 |
+
plugin_day_limit: "Daily limit",
|
| 316 |
+
plugin_permissions: "Permissions",
|
| 317 |
+
plugin_permissions_unavailable: "Permission configuration unavailable",
|
| 318 |
+
plugin_permissions_none: "No permissions granted",
|
| 319 |
+
plugin_permissions_review: "Permissions could not be read. Review the permissions before saving.",
|
| 320 |
+
plugin_created: "Created",
|
| 321 |
+
plugin_edit: "Edit",
|
| 322 |
+
plugin_rotate: "Rotate key",
|
| 323 |
+
plugin_enable: "Enable",
|
| 324 |
+
plugin_disable: "Disable",
|
| 325 |
+
plugin_delete: "Delete",
|
| 326 |
+
plugin_active: "Active",
|
| 327 |
+
plugin_refreshed: "Refreshed",
|
| 328 |
+
plugin_perm_verify: "Verify key",
|
| 329 |
+
plugin_perm_balance: "Read balance",
|
| 330 |
+
plugin_perm_deduct: "Deduct credits",
|
| 331 |
+
plugin_perm_refund: "Refund credits",
|
| 332 |
+
plugin_action_name: "{action}: {plugin}",
|
| 333 |
broadcast_subtitle: 'Publish announcements for all users and manage the top advertisement banner.',
|
| 334 |
announcement_title: '📢 Site announcements', announcement_content: 'Announcement content',
|
| 335 |
announcement_placeholder: 'Enter an announcement for all users…', announcement_publish: '🚀 Publish announcement',
|
|
|
|
| 486 |
config_read_unavailable: 'The current configuration could not be confirmed. Reload it later.',
|
| 487 |
session_changed: 'The session has changed. Continue in the current session.',
|
| 488 |
network_unavailable: 'Network unavailable. Check your connection before retrying.',
|
| 489 |
+
request_timed_out: 'Request timed out. The result is unconfirmed. Check the current state before deciding whether to retry.',
|
| 490 |
+
response_unavailable: 'The server response could not be confirmed. Check the current state before deciding whether to retry.',
|
| 491 |
auth_expired: 'Your session has expired. Sign in again.',
|
| 492 |
admin_verification_unavailable: 'Administrator access could not be confirmed. Sign in again later.',
|
| 493 |
admin_data_unavailable: 'The returned data could not be confirmed. Reload it later.',
|
|
|
|
| 507 |
const USER_KEY = 'ranking_admin_user';
|
| 508 |
const ALL_PERMS = ['verify', 'balance', 'deduct', 'refund'];
|
| 509 |
const PERM_LABELS = {
|
| 510 |
+
verify: 'plugin_perm_verify',
|
| 511 |
+
balance: 'plugin_perm_balance',
|
| 512 |
+
deduct: 'plugin_perm_deduct',
|
| 513 |
+
refund: 'plugin_perm_refund'
|
| 514 |
};
|
| 515 |
|
| 516 |
// -------------------- 状态 --------------------
|
|
|
|
| 929 |
|
| 930 |
// -------------------- API 客户端 --------------------
|
| 931 |
let adminSessionGeneration = 0;
|
| 932 |
+
const ADMIN_REQUEST_TIMEOUT = 45000;
|
| 933 |
+
const ADMIN_UPLOAD_TIMEOUT = 420000;
|
| 934 |
+
const ADMIN_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
|
| 935 |
+
const ADMIN_RESPONSE_MAX_CHUNKS = 16384;
|
| 936 |
const adminRequests = new Set();
|
| 937 |
function revokeAdminRequests() {
|
| 938 |
for (const controller of Array.from(adminRequests)) {
|
|
|
|
| 950 |
throw error;
|
| 951 |
}
|
| 952 |
}
|
| 953 |
+
async function readAdminResponse(response, deadline, session) {
|
| 954 |
+
if (response.body === null) return "";
|
| 955 |
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
| 956 |
+
throw new ApiError(adminMessage('response_unavailable'), 0);
|
| 957 |
+
}
|
| 958 |
+
const reader = response.body.getReader();
|
| 959 |
+
let complete = false;
|
| 960 |
+
try {
|
| 961 |
+
const length = response.headers?.get?.('Content-Length');
|
| 962 |
+
if (length !== null && length !== undefined &&
|
| 963 |
+
(!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)) || Number(length) > ADMIN_RESPONSE_MAX_BYTES)) {
|
| 964 |
+
throw new ApiError(adminMessage('response_unavailable'), 0);
|
| 965 |
+
}
|
| 966 |
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
| 967 |
+
const parts = [];
|
| 968 |
+
let bytes = 0, chunks = 0, text = "";
|
| 969 |
+
while (true) {
|
| 970 |
+
assertAdminSession(session);
|
| 971 |
+
const chunk = await Promise.race([reader.read(), deadline]);
|
| 972 |
+
assertAdminSession(session);
|
| 973 |
+
if (!chunk || typeof chunk.done !== 'boolean') throw new Error('Invalid response stream');
|
| 974 |
+
if (chunk.done) break;
|
| 975 |
+
if (!(chunk.value instanceof Uint8Array) || ++chunks > ADMIN_RESPONSE_MAX_CHUNKS ||
|
| 976 |
+
chunk.value.byteLength > ADMIN_RESPONSE_MAX_BYTES - bytes) {
|
| 977 |
+
throw new ApiError(adminMessage('response_unavailable'), 0);
|
| 978 |
+
}
|
| 979 |
+
bytes += chunk.value.byteLength;
|
| 980 |
+
text += decoder.decode(chunk.value, { stream: true });
|
| 981 |
+
if (text.length >= 65536) { parts.push(text); text = ""; }
|
| 982 |
+
}
|
| 983 |
+
text += decoder.decode();
|
| 984 |
+
parts.push(text);
|
| 985 |
+
complete = true;
|
| 986 |
+
return parts.join("");
|
| 987 |
+
} catch (error) {
|
| 988 |
+
assertAdminSession(session);
|
| 989 |
+
if (error instanceof ApiError) throw error;
|
| 990 |
+
throw new ApiError(adminMessage('response_unavailable'), 0);
|
| 991 |
+
} finally {
|
| 992 |
+
if (!complete) {
|
| 993 |
+
try { Promise.resolve(reader.cancel()).catch(() => {}); } catch (_) { /* 继续释放读锁。 */ }
|
| 994 |
+
}
|
| 995 |
+
try { reader.releaseLock(); } catch (_) { /* 异常流不阻止界面收尾。 */ }
|
| 996 |
+
}
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
async function performAdminRequest(path, options, upload = false) {
|
| 1000 |
const session = captureAdminSession();
|
| 1001 |
const controller = new AbortController();
|
| 1002 |
const headers = { Accept: 'application/json', ...options.headers };
|
| 1003 |
if (session.token) headers.Authorization = 'Bearer ' + session.token;
|
| 1004 |
adminRequests.add(controller);
|
| 1005 |
+
let timer;
|
| 1006 |
+
const deadline = new Promise((resolve, reject) => {
|
| 1007 |
+
timer = setTimeout(() => {
|
| 1008 |
+
reject(new ApiError(adminMessage('request_timed_out'), 0));
|
| 1009 |
+
try { controller.abort(); } catch (_) { /* 撤销异常不阻止等待结束。 */ }
|
| 1010 |
+
}, upload ? ADMIN_UPLOAD_TIMEOUT : ADMIN_REQUEST_TIMEOUT);
|
| 1011 |
+
});
|
| 1012 |
try {
|
| 1013 |
let response;
|
| 1014 |
try {
|
| 1015 |
+
response = await Promise.race([fetch(API_BASE + path, { ...options, headers, signal: controller.signal }), deadline]);
|
| 1016 |
} catch (err) {
|
| 1017 |
assertAdminSession(session);
|
| 1018 |
+
if (err instanceof ApiError) throw err;
|
| 1019 |
throw new ApiError(adminMessage('network_unavailable'), 0);
|
| 1020 |
}
|
| 1021 |
assertAdminSession(session);
|
|
|
|
| 1025 |
}
|
| 1026 |
let text;
|
| 1027 |
try {
|
| 1028 |
+
text = await readAdminResponse(response, deadline, session);
|
| 1029 |
} catch (err) {
|
| 1030 |
+
try { controller.abort(); } catch (_) { /* 保留原读取错误。 */ }
|
| 1031 |
assertAdminSession(session);
|
| 1032 |
+
if (err instanceof ApiError) throw err;
|
| 1033 |
throw new ApiError(adminMessage('network_unavailable'), 0);
|
| 1034 |
}
|
| 1035 |
assertAdminSession(session);
|
|
|
|
| 1048 |
}
|
| 1049 |
return payload;
|
| 1050 |
} finally {
|
| 1051 |
+
clearTimeout(timer);
|
| 1052 |
adminRequests.delete(controller);
|
| 1053 |
}
|
| 1054 |
}
|
|
|
|
| 1073 |
|
| 1074 |
// 🆕 multipart 上传(广告图片):FormData 不序列化,复用同一会话和取消边界。
|
| 1075 |
async function apiUpload(path, formData) {
|
| 1076 |
+
return performAdminRequest(path, { method: 'POST', body: formData }, true);
|
| 1077 |
}
|
| 1078 |
|
| 1079 |
// -------------------- 视图切换 --------------------
|
|
|
|
| 1229 |
}
|
| 1230 |
|
| 1231 |
// -------------------- 插件列表 --------------------
|
| 1232 |
+
let pluginListGeneration = 0;
|
| 1233 |
+
let editPluginCurrent = () => true;
|
| 1234 |
+
|
| 1235 |
function setListState(stateName, message) {
|
| 1236 |
+
if (stateName === 'loading') pluginListGeneration++;
|
| 1237 |
$('#list-loading').hidden = stateName !== 'loading';
|
| 1238 |
$('#list-empty').hidden = stateName !== 'empty';
|
| 1239 |
$('#list-error').hidden = stateName !== 'error';
|
| 1240 |
$('#plugin-grid').hidden = stateName !== 'data';
|
| 1241 |
+
$('#plugin-grid').setAttribute('aria-busy', String(stateName === 'loading'));
|
| 1242 |
if (stateName === 'error' && message) {
|
| 1243 |
$('#list-error-msg').textContent = message;
|
| 1244 |
}
|
|
|
|
| 1297 |
}
|
| 1298 |
|
| 1299 |
function renderList() {
|
| 1300 |
+
const generation = ++pluginListGeneration;
|
| 1301 |
+
const session = captureAdminSession();
|
| 1302 |
const grid = $('#plugin-grid');
|
| 1303 |
const tpl = $('#tpl-plugin-card');
|
| 1304 |
if (!grid || !tpl) return;
|
|
|
|
| 1311 |
grid.innerHTML = '';
|
| 1312 |
|
| 1313 |
if (!state.filtered.length) {
|
| 1314 |
+
grid.innerHTML = '<div class="state-block" role="status" aria-atomic="true" style="grid-column: 1/-1;">' +
|
| 1315 |
+
'<div class="empty-mark" aria-hidden="true">∅</div><h3>' + escapeHtml(adminMessage('plugin_no_match')) +
|
| 1316 |
+
'</h3><p>' + escapeHtml(adminMessage('plugin_no_match_hint')) + '</p></div>';
|
| 1317 |
return;
|
| 1318 |
}
|
| 1319 |
|
| 1320 |
const frag = document.createDocumentFragment();
|
| 1321 |
state.filtered.forEach((plugin) => {
|
| 1322 |
const node = tpl.content.firstElementChild.cloneNode(true);
|
| 1323 |
+
localizeAdminPluginUI(node);
|
| 1324 |
const id = plugin.plugin_id || plugin.id || '';
|
| 1325 |
const name = plugin.name || id;
|
| 1326 |
|
|
|
|
| 1331 |
|
| 1332 |
const badge = $('.js-status', node);
|
| 1333 |
if (plugin.enabled) {
|
| 1334 |
+
badge.textContent = adminMessage('plugin_active');
|
| 1335 |
badge.classList.add('is-on');
|
| 1336 |
} else {
|
| 1337 |
+
badge.textContent = adminMessage('plugin_disabled');
|
| 1338 |
badge.classList.add('is-off');
|
| 1339 |
}
|
| 1340 |
|
|
|
|
| 1344 |
$('.js-created', node).textContent = formatDate(plugin.created_at || plugin.create_time || plugin.createdAt);
|
| 1345 |
|
| 1346 |
const permsEl = $('.js-perms', node);
|
| 1347 |
+
const perms = plugin.permissions_valid !== false && Array.isArray(plugin.permissions) ? plugin.permissions : [];
|
| 1348 |
+
if (plugin.permissions_valid === false) {
|
| 1349 |
+
permsEl.textContent = adminMessage('plugin_permissions_unavailable');
|
| 1350 |
+
} else if (perms.length) {
|
| 1351 |
+
permsEl.innerHTML = perms.map((p) => `<span class="perm-chip" title="${escapeHtml(p)}">${escapeHtml(ALL_PERMS.includes(p) ? adminMessage(PERM_LABELS[p]) : p)}</span>`).join('');
|
| 1352 |
} else {
|
| 1353 |
+
permsEl.textContent = adminMessage('plugin_permissions_none');
|
| 1354 |
}
|
| 1355 |
|
| 1356 |
const toggleBtn = $('.js-toggle', node);
|
| 1357 |
+
toggleBtn.textContent = adminMessage(plugin.enabled ? 'plugin_disable' : 'plugin_enable');
|
| 1358 |
+
|
| 1359 |
+
const isCurrent = () => generation === pluginListGeneration && node.isConnected && session.isCurrent();
|
| 1360 |
|
| 1361 |
// 操作绑定
|
| 1362 |
$$('button[data-act]', node).forEach((btn) => {
|
| 1363 |
+
btn.setAttribute('aria-label', adminMessage('plugin_action_name')
|
| 1364 |
+
.replace('{action}', () => btn.textContent).replace('{plugin}', () => name));
|
| 1365 |
+
btn.addEventListener('click', () => onCardAction(btn.dataset.act, plugin, btn, isCurrent));
|
| 1366 |
});
|
| 1367 |
|
| 1368 |
frag.appendChild(node);
|
|
|
|
| 1370 |
grid.appendChild(frag);
|
| 1371 |
}
|
| 1372 |
|
| 1373 |
+
function onCardAction(act, plugin, btn, parentCurrent = () => true) {
|
| 1374 |
+
if (!parentCurrent()) return;
|
| 1375 |
+
if (act === 'edit') return openEditModal(plugin, parentCurrent);
|
| 1376 |
+
if (act === 'rotate') return rotateKey(plugin, btn, parentCurrent);
|
| 1377 |
+
if (act === 'toggle') return togglePlugin(plugin, btn, parentCurrent);
|
| 1378 |
+
if (act === 'delete') return deletePlugin(plugin, btn, parentCurrent);
|
| 1379 |
}
|
| 1380 |
|
| 1381 |
// -------------------- 模态框 --------------------
|
|
|
|
| 1387 |
adminModalFocus().open(el);
|
| 1388 |
}
|
| 1389 |
function closeModal(id) {
|
| 1390 |
+
if (id === 'modal-key') {
|
| 1391 |
+
resetAdminKeyCopy();
|
| 1392 |
+
const key = $('#key-display');
|
| 1393 |
+
if (key) key.textContent = '';
|
| 1394 |
+
}
|
| 1395 |
if (id === 'modal-confirm') state.confirmHandler = null;
|
| 1396 |
if (id === 'modal-dispute') adminReadOperations.delete('dispute-detail');
|
| 1397 |
const el = document.getElementById(id) || adminModalFocusController?.findMask(id);
|
|
|
|
| 1449 |
const fd = new FormData(form);
|
| 1450 |
const pluginId = String(fd.get('plugin_id') || '').trim();
|
| 1451 |
const name = String(fd.get('name') || '').trim();
|
| 1452 |
+
if (!/^[a-z0-9_]{3,50}$/.test(pluginId)) {
|
| 1453 |
+
showError(errBox, adminMessage('plugin_id_invalid'));
|
| 1454 |
+
form.querySelector('[name="plugin_id"]').focus();
|
| 1455 |
return;
|
| 1456 |
}
|
| 1457 |
if (!name) {
|
| 1458 |
+
showError(errBox, adminMessage('plugin_name_required'));
|
| 1459 |
+
form.querySelector('[name="name"]').focus();
|
| 1460 |
+
return;
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
let fields;
|
| 1464 |
+
try { fields = readPluginFields(fd, name); }
|
| 1465 |
+
catch (error) {
|
| 1466 |
+
showError(errBox, error.message);
|
| 1467 |
+
form.querySelector('[name="' + error.field + '"]').focus();
|
| 1468 |
return;
|
| 1469 |
}
|
| 1470 |
|
| 1471 |
const payload = {
|
| 1472 |
plugin_id: pluginId,
|
| 1473 |
name: name,
|
| 1474 |
+
...fields,
|
|
|
|
|
|
|
|
|
|
| 1475 |
permissions: fd.getAll('permissions'),
|
| 1476 |
};
|
| 1477 |
|
|
|
|
| 1485 |
modal.assertCurrent();
|
| 1486 |
const result = confirmAdminPluginWrite(data, pluginId, 'create');
|
| 1487 |
closeModal('modal-create');
|
| 1488 |
+
showKeyModal(result.plugin_key, adminMessage('plugin_key_save_hint'));
|
| 1489 |
await loadPlugins();
|
| 1490 |
} catch (err) {
|
| 1491 |
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
|
| 1492 |
+
showError(errBox, err.message || adminMessage('plugin_create_failed'));
|
| 1493 |
} finally {
|
| 1494 |
finishBusy();
|
| 1495 |
}
|
| 1496 |
});
|
| 1497 |
}
|
| 1498 |
|
| 1499 |
+
const PLUGIN_LIMITS = {
|
| 1500 |
+
rate_limit: { initial: 100, max: 10000, labelKey: 'plugin_rate_label' },
|
| 1501 |
+
max_deduct_per_tx: { initial: 100, max: 10000, labelKey: 'plugin_tx_label' },
|
| 1502 |
+
max_deduct_per_day: { initial: 1000, max: 100000, labelKey: 'plugin_day_label' }
|
| 1503 |
+
};
|
| 1504 |
+
|
| 1505 |
+
function readPluginFields(fd, name) {
|
| 1506 |
+
const reject = (field, message) => {
|
| 1507 |
+
const error = new ApiError(message, 0);
|
| 1508 |
+
error.field = field;
|
| 1509 |
+
throw error;
|
| 1510 |
+
};
|
| 1511 |
+
if (Array.from(name).length > 100) reject('name', adminMessage('plugin_name_long'));
|
| 1512 |
+
const description = String(fd.get('description') || '').trim();
|
| 1513 |
+
if (Array.from(description).length > 500) reject('description', adminMessage('plugin_description_long'));
|
| 1514 |
+
const fields = { description };
|
| 1515 |
+
for (const [field, rule] of Object.entries(PLUGIN_LIMITS)) {
|
| 1516 |
+
const raw = fd.get(field);
|
| 1517 |
+
const value = raw === null || raw === '' ? rule.initial : typeof raw === 'string' ? Number(raw) : NaN;
|
| 1518 |
+
if (!Number.isSafeInteger(value) || value < 1 || value > rule.max) {
|
| 1519 |
+
const message = adminMessage('plugin_limit_invalid')
|
| 1520 |
+
.replace('{field}', () => adminMessage(rule.labelKey)).replace('{max}', () => rule.max);
|
| 1521 |
+
reject(field, message);
|
| 1522 |
+
}
|
| 1523 |
+
fields[field] = value;
|
| 1524 |
+
}
|
| 1525 |
+
return fields;
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
function toIntOr(value, fallback) {
|
| 1529 |
const num = parseInt(value, 10);
|
| 1530 |
return isNaN(num) ? fallback : num;
|
| 1531 |
}
|
| 1532 |
|
| 1533 |
// -------------------- 编辑插件 --------------------
|
| 1534 |
+
function openEditModal(plugin, parentCurrent = () => true) {
|
| 1535 |
+
if (!parentCurrent()) return;
|
| 1536 |
+
const session = captureAdminSession();
|
| 1537 |
+
editPluginCurrent = () => session.isCurrent() && parentCurrent();
|
| 1538 |
const form = $('#form-edit');
|
| 1539 |
form.reset();
|
| 1540 |
showError($('#form-edit-error'), '');
|
|
|
|
| 1549 |
form.querySelector('[name="max_deduct_per_tx"]').value = plugin.max_deduct_per_tx ?? 100;
|
| 1550 |
form.querySelector('[name="max_deduct_per_day"]').value = plugin.max_deduct_per_day ?? 1000;
|
| 1551 |
|
| 1552 |
+
const perms = plugin.permissions_valid !== false && Array.isArray(plugin.permissions) ? plugin.permissions : [];
|
| 1553 |
$$('#form-edit input[name="permissions"]').forEach((cb) => {
|
| 1554 |
cb.checked = perms.includes(cb.value);
|
| 1555 |
});
|
| 1556 |
|
| 1557 |
+
if (plugin.permissions_valid === false) {
|
| 1558 |
+
showError($('#form-edit-error'), adminMessage('plugin_permissions_review'));
|
| 1559 |
+
}
|
| 1560 |
$('#edit-key-display').textContent = plugin.plugin_key || maskKey(id);
|
| 1561 |
|
| 1562 |
openModal('modal-edit');
|
|
|
|
| 1566 |
const form = $('#form-edit');
|
| 1567 |
form.addEventListener('submit', async (event) => {
|
| 1568 |
event.preventDefault();
|
| 1569 |
+
const isCurrent = editPluginCurrent;
|
| 1570 |
+
if (!isCurrent()) return;
|
| 1571 |
const errBox = $('#form-edit-error');
|
| 1572 |
showError(errBox, '');
|
| 1573 |
|
| 1574 |
const id = form.dataset.pluginId;
|
| 1575 |
+
if (!id) { showError(errBox, adminMessage('plugin_id_missing')); return; }
|
| 1576 |
|
| 1577 |
const fd = new FormData(form);
|
| 1578 |
const name = String(fd.get('name') || '').trim();
|
| 1579 |
+
if (!name) { showError(errBox, adminMessage('plugin_name_required')); form.querySelector('[name="name"]').focus(); return; }
|
| 1580 |
+
|
| 1581 |
+
let fields;
|
| 1582 |
+
try { fields = readPluginFields(fd, name); }
|
| 1583 |
+
catch (error) {
|
| 1584 |
+
showError(errBox, error.message);
|
| 1585 |
+
form.querySelector('[name="' + error.field + '"]').focus();
|
| 1586 |
+
return;
|
| 1587 |
+
}
|
| 1588 |
|
| 1589 |
const payload = {
|
| 1590 |
name: name,
|
| 1591 |
+
...fields,
|
|
|
|
|
|
|
|
|
|
| 1592 |
permissions: fd.getAll('permissions'),
|
| 1593 |
};
|
| 1594 |
|
|
|
|
| 1600 |
const response = await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
|
| 1601 |
finishBusy.assertCurrent();
|
| 1602 |
modal.assertCurrent();
|
| 1603 |
+
if (!isCurrent()) return;
|
| 1604 |
confirmAdminPluginWrite(response, id, 'edit');
|
| 1605 |
closeModal('modal-edit');
|
| 1606 |
+
toast(adminMessage('plugin_saved'), 'success');
|
| 1607 |
await loadPlugins();
|
| 1608 |
} catch (err) {
|
| 1609 |
+
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent() || !isCurrent()) return;
|
| 1610 |
+
showError(errBox, err.message || adminMessage('plugin_edit_failed'));
|
| 1611 |
} finally {
|
| 1612 |
finishBusy();
|
| 1613 |
}
|
|
|
|
| 1615 |
}
|
| 1616 |
|
| 1617 |
// -------------------- 轮换 / 切换 / 删除 --------------------
|
| 1618 |
+
function rotateKey(plugin, btn, parentCurrent = () => true) {
|
| 1619 |
+
if (!parentCurrent()) return;
|
| 1620 |
+
const session = captureAdminSession();
|
| 1621 |
+
const isCurrent = () => session.isCurrent() && parentCurrent();
|
| 1622 |
const id = plugin.plugin_id || plugin.id;
|
| 1623 |
confirmAction({
|
| 1624 |
+
title: adminMessage('plugin_rotate'),
|
| 1625 |
+
message: adminMessage('plugin_rotate_message').replace('{plugin}', () => plugin.name || id),
|
| 1626 |
+
okText: adminMessage('plugin_rotate_confirm'),
|
| 1627 |
danger: false,
|
| 1628 |
onConfirm: async (assertCurrent) => {
|
| 1629 |
+
assertAdminSession({ isCurrent });
|
| 1630 |
+
assertCurrent();
|
| 1631 |
const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
|
| 1632 |
assertCurrent();
|
| 1633 |
+
assertAdminSession({ isCurrent });
|
| 1634 |
const result = confirmAdminPluginWrite(data, id, 'rotate');
|
| 1635 |
+
showKeyModal(result.plugin_key, adminMessage('plugin_key_rotated_hint'));
|
| 1636 |
await loadPlugins();
|
| 1637 |
},
|
| 1638 |
});
|
| 1639 |
}
|
| 1640 |
|
| 1641 |
+
function togglePlugin(plugin, btn, parentCurrent = () => true) {
|
| 1642 |
+
if (!parentCurrent()) return;
|
| 1643 |
const id = plugin.plugin_id || plugin.id;
|
| 1644 |
const finishBusy = startAdminBusy(btn);
|
| 1645 |
if (!finishBusy) return;
|
| 1646 |
apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
|
| 1647 |
.then(async (response) => {
|
| 1648 |
finishBusy.assertCurrent();
|
| 1649 |
+
if (!parentCurrent()) return;
|
| 1650 |
const result = confirmAdminPluginWrite(response, id, 'toggle');
|
| 1651 |
+
toast(adminMessage(result.enabled ? 'plugin_now_enabled' : 'plugin_now_disabled'), 'success');
|
| 1652 |
await loadPlugins();
|
| 1653 |
})
|
| 1654 |
.catch((err) => {
|
| 1655 |
+
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !parentCurrent()) return;
|
| 1656 |
+
toast(err.message || adminMessage('plugin_toggle_failed'), 'error');
|
| 1657 |
})
|
| 1658 |
.finally(() => finishBusy());
|
| 1659 |
}
|
| 1660 |
|
| 1661 |
+
function deletePlugin(plugin, btn, parentCurrent = () => true) {
|
| 1662 |
+
if (!parentCurrent()) return;
|
| 1663 |
+
const session = captureAdminSession();
|
| 1664 |
+
const isCurrent = () => session.isCurrent() && parentCurrent();
|
| 1665 |
const id = plugin.plugin_id || plugin.id;
|
| 1666 |
confirmAction({
|
| 1667 |
+
title: adminMessage('plugin_delete'),
|
| 1668 |
+
message: adminMessage('plugin_delete_message').replace('{plugin}', () => plugin.name || id),
|
| 1669 |
+
okText: adminMessage('plugin_delete_confirm'),
|
| 1670 |
danger: true,
|
| 1671 |
onConfirm: async (assertCurrent) => {
|
| 1672 |
+
assertAdminSession({ isCurrent });
|
| 1673 |
+
assertCurrent();
|
| 1674 |
const response = await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
|
| 1675 |
assertCurrent();
|
| 1676 |
+
assertAdminSession({ isCurrent });
|
| 1677 |
confirmAdminPluginWrite(response, id, 'delete');
|
| 1678 |
+
toast(adminMessage('plugin_deleted'), 'success');
|
| 1679 |
await loadPlugins();
|
| 1680 |
},
|
| 1681 |
});
|
|
|
|
| 2766 |
}
|
| 2767 |
|
| 2768 |
// -------------------- 主视图绑定 --------------------
|
| 2769 |
+
function localizeAdminPluginUI(root = document) {
|
| 2770 |
+
$$('[data-admin-plugin-text]', root).forEach(node => { node.textContent = adminMessage(node.dataset.adminPluginText); });
|
| 2771 |
+
for (const [kind, attribute] of [['placeholder', 'placeholder'], ['label', 'aria-label'], ['title', 'title'], ['empty', 'data-empty-description']]) {
|
| 2772 |
+
$$('[data-admin-plugin-' + kind + ']', root).forEach(node => {
|
| 2773 |
+
const key = node.dataset['adminPlugin' + kind[0].toUpperCase() + kind.slice(1)];
|
| 2774 |
+
node.setAttribute(attribute, adminMessage(key));
|
| 2775 |
+
});
|
| 2776 |
+
}
|
| 2777 |
+
}
|
| 2778 |
+
|
| 2779 |
function bindMain() {
|
| 2780 |
+
localizeAdminPluginUI();
|
| 2781 |
$('#btn-logout').addEventListener('click', () => {
|
| 2782 |
confirmAction({
|
| 2783 |
title: adminMessage('logout_title'),
|
|
|
|
| 2799 |
try {
|
| 2800 |
await loadPlugins();
|
| 2801 |
finishBusy.assertCurrent();
|
| 2802 |
+
toast(adminMessage('plugin_refreshed'), 'success');
|
| 2803 |
} catch (err) {
|
| 2804 |
if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
|
| 2805 |
if (!(err instanceof ApiError && err.status === 401)) {
|
| 2806 |
+
setListState('error', err.message || adminMessage('plugin_load_failed'));
|
| 2807 |
+
toast(err.message || adminMessage('plugin_load_failed'), 'error');
|
| 2808 |
}
|
| 2809 |
} finally {
|
| 2810 |
finishBusy();
|
|
|
|
| 2816 |
catch (err) {
|
| 2817 |
if (isStaleSessionError(err)) return;
|
| 2818 |
if (!(err instanceof ApiError && err.status === 401)) {
|
| 2819 |
+
setListState('error', err.message || adminMessage('plugin_load_failed'));
|
| 2820 |
}
|
| 2821 |
}
|
| 2822 |
});
|
管理界面/index.html
CHANGED
|
@@ -81,8 +81,8 @@
|
|
| 81 |
<div class="brand-sub">Plugin Admin</div>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
-
<nav class="crumbs" aria-label="位置">
|
| 85 |
-
<span>控制台</span>
|
| 86 |
<span class="crumbs-sep">/</span>
|
| 87 |
<span class="crumbs-current">第三方插件</span>
|
| 88 |
</nav>
|
|
@@ -112,53 +112,53 @@
|
|
| 112 |
<div class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins" tabindex="0">
|
| 113 |
<section class="page-head">
|
| 114 |
<div class="page-head-text">
|
| 115 |
-
<h1>第三方插件</h1>
|
| 116 |
-
<p class="page-sub">管理 plugin_key、限流配额、权限与启用状态。</p>
|
| 117 |
</div>
|
| 118 |
<div class="page-head-actions">
|
| 119 |
<div class="search-wrap">
|
| 120 |
-
<input type="search" id="search-input" placeholder="搜索 plugin_id 或 名称…">
|
| 121 |
</div>
|
| 122 |
-
<button type="button" id="btn-refresh" class="btn btn-secondary" title="刷新">
|
| 123 |
-
<span class="btn-label">刷新</span>
|
| 124 |
</button>
|
| 125 |
<button type="button" id="btn-create" class="btn btn-primary">
|
| 126 |
<span class="btn-icon" aria-hidden="true">+</span>
|
| 127 |
-
<span class="btn-label">创建新插件</span>
|
| 128 |
</button>
|
| 129 |
</div>
|
| 130 |
</section>
|
| 131 |
|
| 132 |
<section class="stat-row" id="stat-row">
|
| 133 |
<div class="stat-card">
|
| 134 |
-
<div class="stat-label">插件总数</div>
|
| 135 |
<div class="stat-value" id="stat-total">—</div>
|
| 136 |
</div>
|
| 137 |
<div class="stat-card">
|
| 138 |
-
<div class="stat-label">已启用</div>
|
| 139 |
<div class="stat-value text-success" id="stat-enabled">—</div>
|
| 140 |
</div>
|
| 141 |
<div class="stat-card">
|
| 142 |
-
<div class="stat-label">已禁用</div>
|
| 143 |
<div class="stat-value text-warning" id="stat-disabled">—</div>
|
| 144 |
</div>
|
| 145 |
</section>
|
| 146 |
|
| 147 |
<section class="list-shell">
|
| 148 |
-
<div id="list-loading" class="state-block" hidden>
|
| 149 |
<div class="spinner-lg" aria-hidden="true"></div>
|
| 150 |
-
<p>正在加载插件列表…</p>
|
| 151 |
</div>
|
| 152 |
-
<div id="list-empty" class="state-block" hidden>
|
| 153 |
<div class="empty-mark">∅</div>
|
| 154 |
-
<h3>暂无插件</h3>
|
| 155 |
-
<p>点击右上角「创建新插件」开始接入第三方应用。</p>
|
| 156 |
</div>
|
| 157 |
-
<div id="list-error" class="state-block state-error" hidden>
|
| 158 |
<div class="empty-mark">!</div>
|
| 159 |
-
<h3>加载失败</h3>
|
| 160 |
-
<p id="list-error-msg">请稍后重试。</p>
|
| 161 |
-
<button type="button" class="btn btn-secondary" id="btn-retry">重新加载</button>
|
| 162 |
</div>
|
| 163 |
<div id="plugin-grid" class="plugin-grid"></div>
|
| 164 |
</section>
|
|
@@ -482,59 +482,59 @@
|
|
| 482 |
<div id="modal-create" class="modal-mask" hidden>
|
| 483 |
<div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
|
| 484 |
<header class="modal-header">
|
| 485 |
-
<h3 id="modal-create-title">创建新插件</h3>
|
| 486 |
-
<button type="button" class="modal-close" data-close="modal-create" aria-label="关闭">×</button>
|
| 487 |
</header>
|
| 488 |
<form id="form-create" class="modal-body" novalidate>
|
|
|
|
| 489 |
<div class="grid-2">
|
| 490 |
<label class="field">
|
| 491 |
<span class="field-label">plugin_id <span class="required">*</span></span>
|
| 492 |
-
<input type="text" name="plugin_id" required pattern="^[
|
| 493 |
-
placeholder="英文/数字/下划线,3-50 字符">
|
| 494 |
-
<span class="field-hint">用于标识插件,创建后不可修改。</span>
|
| 495 |
</label>
|
| 496 |
<label class="field">
|
| 497 |
-
<span class="field-label">名称 <span class="required">*</span></span>
|
| 498 |
-
<input type="text" name="name" required placeholder="例如:示例插件">
|
| 499 |
</label>
|
| 500 |
</div>
|
| 501 |
|
| 502 |
<label class="field">
|
| 503 |
-
<span class="field-label">描述</span>
|
| 504 |
-
<textarea name="description" rows="2" placeholder="对该插件的简要说明(可选)"></textarea>
|
| 505 |
</label>
|
| 506 |
|
| 507 |
<div class="grid-3">
|
| 508 |
<label class="field">
|
| 509 |
-
<span class="field-label">每分钟限流</span>
|
| 510 |
-
<input type="number" name="rate_limit" min="1" value="100">
|
| 511 |
</label>
|
| 512 |
<label class="field">
|
| 513 |
-
<span class="field-label">单笔扣款上限</span>
|
| 514 |
-
<input type="number" name="max_deduct_per_tx" min="
|
| 515 |
</label>
|
| 516 |
<label class="field">
|
| 517 |
-
<span class="field-label">日扣款上限</span>
|
| 518 |
-
<input type="number" name="max_deduct_per_day" min="
|
| 519 |
</label>
|
| 520 |
</div>
|
| 521 |
|
| 522 |
<fieldset class="field perm-field">
|
| 523 |
-
<legend class="field-label">权限</legend>
|
| 524 |
<div class="perm-grid">
|
| 525 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="verify" checked><span>验证 Key</span></label>
|
| 526 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="balance" checked><span>查询余额</span></label>
|
| 527 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="deduct" checked><span>扣除积分</span></label>
|
| 528 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>退款回滚</span></label>
|
| 529 |
</div>
|
| 530 |
</fieldset>
|
| 531 |
|
| 532 |
-
<div class="form-error" id="form-create-error" hidden></div>
|
| 533 |
</form>
|
| 534 |
<footer class="modal-footer">
|
| 535 |
-
<button type="button" class="btn btn-ghost" data-close="modal-create">取消</button>
|
| 536 |
<button type="submit" form="form-create" id="btn-create-submit" class="btn btn-primary">
|
| 537 |
-
<span class="btn-label">创建插件</span>
|
| 538 |
<span class="btn-spinner" aria-hidden="true"></span>
|
| 539 |
</button>
|
| 540 |
</footer>
|
|
@@ -545,64 +545,64 @@
|
|
| 545 |
<div id="modal-edit" class="modal-mask" hidden>
|
| 546 |
<div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
|
| 547 |
<header class="modal-header">
|
| 548 |
-
<h3 id="modal-edit-title">编辑插件</h3>
|
| 549 |
-
<button type="button" class="modal-close" data-close="modal-edit" aria-label="关闭">×</button>
|
| 550 |
</header>
|
| 551 |
<form id="form-edit" class="modal-body" novalidate>
|
|
|
|
| 552 |
<div class="grid-2">
|
| 553 |
<label class="field">
|
| 554 |
<span class="field-label">plugin_id</span>
|
| 555 |
<input type="text" name="plugin_id" disabled>
|
| 556 |
-
<span class="field-hint">不可修改。</span>
|
| 557 |
</label>
|
| 558 |
<label class="field">
|
| 559 |
-
<span class="field-label">名称 <span class="required">*</span></span>
|
| 560 |
<input type="text" name="name" required>
|
| 561 |
</label>
|
| 562 |
</div>
|
| 563 |
|
| 564 |
<label class="field">
|
| 565 |
-
<span class="field-label">描述</span>
|
| 566 |
<textarea name="description" rows="2"></textarea>
|
| 567 |
</label>
|
| 568 |
|
| 569 |
<div class="grid-3">
|
| 570 |
<label class="field">
|
| 571 |
-
<span class="field-label">每分钟限流</span>
|
| 572 |
-
<input type="number" name="rate_limit" min="1">
|
| 573 |
</label>
|
| 574 |
<label class="field">
|
| 575 |
-
<span class="field-label">单笔扣款上限</span>
|
| 576 |
-
<input type="number" name="max_deduct_per_tx" min="
|
| 577 |
</label>
|
| 578 |
<label class="field">
|
| 579 |
-
<span class="field-label">日扣款上限</span>
|
| 580 |
-
<input type="number" name="max_deduct_per_day" min="
|
| 581 |
</label>
|
| 582 |
</div>
|
| 583 |
|
| 584 |
<fieldset class="field perm-field">
|
| 585 |
-
<legend class="field-label">权限</legend>
|
| 586 |
<div class="perm-grid">
|
| 587 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="verify"><span>验证 Key</span></label>
|
| 588 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="balance"><span>查询余额</span></label>
|
| 589 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="deduct"><span>扣除积分</span></label>
|
| 590 |
-
<label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>退款回滚</span></label>
|
| 591 |
</div>
|
| 592 |
</fieldset>
|
| 593 |
|
| 594 |
<div class="field">
|
| 595 |
<span class="field-label">plugin_key</span>
|
| 596 |
<div class="static-key" id="edit-key-display">—</div>
|
| 597 |
-
<span class="field-hint">出于安全考虑,完整 Key 已隐藏。如需更换请使用「轮换 Key」。</span>
|
| 598 |
</div>
|
| 599 |
|
| 600 |
-
<div class="form-error" id="form-edit-error" hidden></div>
|
| 601 |
</form>
|
| 602 |
<footer class="modal-footer">
|
| 603 |
-
<button type="button" class="btn btn-ghost" data-close="modal-edit">取消</button>
|
| 604 |
<button type="submit" form="form-edit" id="btn-edit-submit" class="btn btn-primary">
|
| 605 |
-
<span class="btn-label">保存修改</span>
|
| 606 |
<span class="btn-spinner" aria-hidden="true"></span>
|
| 607 |
</button>
|
| 608 |
</footer>
|
|
@@ -613,24 +613,24 @@
|
|
| 613 |
<div id="modal-key" class="modal-mask" hidden>
|
| 614 |
<div class="modal modal-md" role="dialog" aria-modal="true" aria-labelledby="modal-key-title">
|
| 615 |
<header class="modal-header">
|
| 616 |
-
<h3 id="modal-key-title">插件 Key 已生成</h3>
|
| 617 |
-
<button type="button" class="modal-close" data-close="modal-key" aria-label="关闭">×</button>
|
| 618 |
</header>
|
| 619 |
<div class="modal-body">
|
| 620 |
<div class="key-callout">
|
| 621 |
<div class="key-callout-label">PLUGIN KEY</div>
|
| 622 |
<div class="key-callout-value" id="key-display">—</div>
|
| 623 |
<button type="button" id="btn-copy-key" class="btn btn-secondary btn-copy">
|
| 624 |
-
<span class="btn-label">复制 Key</span>
|
| 625 |
</button>
|
| 626 |
</div>
|
| 627 |
<div class="key-warn">
|
| 628 |
-
<strong>请妥善保存此 Key</strong>
|
| 629 |
-
<span>关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。</span>
|
| 630 |
</div>
|
| 631 |
</div>
|
| 632 |
<footer class="modal-footer">
|
| 633 |
-
<button type="button" class="btn btn-primary" data-close="modal-key">我已保存</button>
|
| 634 |
</footer>
|
| 635 |
</div>
|
| 636 |
</div>
|
|
@@ -687,23 +687,23 @@
|
|
| 687 |
</div>
|
| 688 |
<span class="badge js-status">—</span>
|
| 689 |
</header>
|
| 690 |
-
<p class="plugin-desc js-desc"></p>
|
| 691 |
<div class="plugin-key-row">
|
| 692 |
<span class="key-label">KEY</span>
|
| 693 |
<code class="js-key">—</code>
|
| 694 |
</div>
|
| 695 |
<dl class="plugin-meta">
|
| 696 |
-
<div><dt>限流</dt><dd><span class="js-rate">—</span> <em>次/分钟</em></dd></div>
|
| 697 |
-
<div><dt>单笔限额</dt><dd class="js-tx">—</dd></div>
|
| 698 |
-
<div><dt>日限额</dt><dd class="js-day">—</dd></div>
|
| 699 |
-
<div><dt>权限</dt><dd class="js-perms">—</dd></div>
|
| 700 |
-
<div class="plugin-meta-full"><dt>创建时间</dt><dd class="js-created">—</dd></div>
|
| 701 |
</dl>
|
| 702 |
<footer class="plugin-card-foot">
|
| 703 |
-
<button type="button" class="btn btn-ghost btn-sm" data-act="edit">编辑</button>
|
| 704 |
-
<button type="button" class="btn btn-ghost btn-sm" data-act="rotate">轮换 Key</button>
|
| 705 |
-
<button type="button" class="btn btn-ghost btn-sm js-toggle" data-act="toggle">启用</button>
|
| 706 |
-
<button type="button" class="btn btn-ghost btn-sm btn-danger-text" data-act="delete">删除</button>
|
| 707 |
</footer>
|
| 708 |
</article>
|
| 709 |
</template>
|
|
|
|
| 81 |
<div class="brand-sub">Plugin Admin</div>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
+
<nav class="crumbs" aria-label="位置" data-admin-plugin-label="plugin_location">
|
| 85 |
+
<span data-admin-plugin-text="plugin_console">控制台</span>
|
| 86 |
<span class="crumbs-sep">/</span>
|
| 87 |
<span class="crumbs-current">第三方插件</span>
|
| 88 |
</nav>
|
|
|
|
| 112 |
<div class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins" tabindex="0">
|
| 113 |
<section class="page-head">
|
| 114 |
<div class="page-head-text">
|
| 115 |
+
<h1 data-admin-plugin-text="plugin_title">第三方插件</h1>
|
| 116 |
+
<p class="page-sub" data-admin-plugin-text="plugin_subtitle">管理 plugin_key、限流配额、权限与启用状态。</p>
|
| 117 |
</div>
|
| 118 |
<div class="page-head-actions">
|
| 119 |
<div class="search-wrap">
|
| 120 |
+
<input type="search" id="search-input" data-admin-plugin-label="plugin_search" data-admin-plugin-placeholder="plugin_search_placeholder" placeholder="搜索 plugin_id 或 名称…">
|
| 121 |
</div>
|
| 122 |
+
<button type="button" id="btn-refresh" data-admin-plugin-title="plugin_refresh" class="btn btn-secondary" title="刷新">
|
| 123 |
+
<span class="btn-label" data-admin-plugin-text="plugin_refresh">刷新</span>
|
| 124 |
</button>
|
| 125 |
<button type="button" id="btn-create" class="btn btn-primary">
|
| 126 |
<span class="btn-icon" aria-hidden="true">+</span>
|
| 127 |
+
<span class="btn-label" data-admin-plugin-text="plugin_create">创建新插件</span>
|
| 128 |
</button>
|
| 129 |
</div>
|
| 130 |
</section>
|
| 131 |
|
| 132 |
<section class="stat-row" id="stat-row">
|
| 133 |
<div class="stat-card">
|
| 134 |
+
<div class="stat-label" data-admin-plugin-text="plugin_total">插件总数</div>
|
| 135 |
<div class="stat-value" id="stat-total">—</div>
|
| 136 |
</div>
|
| 137 |
<div class="stat-card">
|
| 138 |
+
<div class="stat-label" data-admin-plugin-text="plugin_enabled">已启用</div>
|
| 139 |
<div class="stat-value text-success" id="stat-enabled">—</div>
|
| 140 |
</div>
|
| 141 |
<div class="stat-card">
|
| 142 |
+
<div class="stat-label" data-admin-plugin-text="plugin_disabled">已禁用</div>
|
| 143 |
<div class="stat-value text-warning" id="stat-disabled">—</div>
|
| 144 |
</div>
|
| 145 |
</section>
|
| 146 |
|
| 147 |
<section class="list-shell">
|
| 148 |
+
<div id="list-loading" role="status" aria-atomic="true" class="state-block" hidden>
|
| 149 |
<div class="spinner-lg" aria-hidden="true"></div>
|
| 150 |
+
<p data-admin-plugin-text="plugin_loading">正在加载插件列表…</p>
|
| 151 |
</div>
|
| 152 |
+
<div id="list-empty" role="status" aria-atomic="true" class="state-block" hidden>
|
| 153 |
<div class="empty-mark">∅</div>
|
| 154 |
+
<h3 data-admin-plugin-text="plugin_empty">暂无插件</h3>
|
| 155 |
+
<p data-admin-plugin-text="plugin_empty_hint">点击右上角「创建新插件」开始接入第三方应用。</p>
|
| 156 |
</div>
|
| 157 |
+
<div id="list-error" role="alert" aria-atomic="true" class="state-block state-error" hidden>
|
| 158 |
<div class="empty-mark">!</div>
|
| 159 |
+
<h3 data-admin-plugin-text="plugin_load_failed">加载失败</h3>
|
| 160 |
+
<p id="list-error-msg" data-admin-plugin-text="plugin_retry_hint">请稍后重试。</p>
|
| 161 |
+
<button type="button" class="btn btn-secondary" id="btn-retry" data-admin-plugin-text="plugin_retry">重新加载</button>
|
| 162 |
</div>
|
| 163 |
<div id="plugin-grid" class="plugin-grid"></div>
|
| 164 |
</section>
|
|
|
|
| 482 |
<div id="modal-create" class="modal-mask" hidden>
|
| 483 |
<div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
|
| 484 |
<header class="modal-header">
|
| 485 |
+
<h3 id="modal-create-title" data-admin-plugin-text="plugin_create">创建新插件</h3>
|
| 486 |
+
<button type="button" class="modal-close" data-close="modal-create" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
|
| 487 |
</header>
|
| 488 |
<form id="form-create" class="modal-body" novalidate>
|
| 489 |
+
<div class="form-error" id="form-create-error" role="alert" aria-atomic="true" hidden></div>
|
| 490 |
<div class="grid-2">
|
| 491 |
<label class="field">
|
| 492 |
<span class="field-label">plugin_id <span class="required">*</span></span>
|
| 493 |
+
<input type="text" name="plugin_id" required pattern="^[a-z0-9_]{3,50}$" maxlength="50"
|
| 494 |
+
placeholder="小写英文/数字/下划线,3-50 字符" data-admin-plugin-placeholder="plugin_id_placeholder">
|
| 495 |
+
<span class="field-hint" data-admin-plugin-text="plugin_id_hint">用于标识插件,创建后不可修改。</span>
|
| 496 |
</label>
|
| 497 |
<label class="field">
|
| 498 |
+
<span class="field-label"><span data-admin-plugin-text="plugin_name">名称</span> <span class="required">*</span></span>
|
| 499 |
+
<input type="text" name="name" required placeholder="例如:示例插件" data-admin-plugin-placeholder="plugin_name_placeholder">
|
| 500 |
</label>
|
| 501 |
</div>
|
| 502 |
|
| 503 |
<label class="field">
|
| 504 |
+
<span class="field-label" data-admin-plugin-text="plugin_description">描述</span>
|
| 505 |
+
<textarea name="description" rows="2" placeholder="对该插件的简要说明(可选)" data-admin-plugin-placeholder="plugin_description_placeholder"></textarea>
|
| 506 |
</label>
|
| 507 |
|
| 508 |
<div class="grid-3">
|
| 509 |
<label class="field">
|
| 510 |
+
<span class="field-label" data-admin-plugin-text="plugin_rate_label">每分钟限流</span>
|
| 511 |
+
<input type="number" name="rate_limit" min="1" max="10000" step="1" value="100">
|
| 512 |
</label>
|
| 513 |
<label class="field">
|
| 514 |
+
<span class="field-label" data-admin-plugin-text="plugin_tx_label">单笔扣款上限</span>
|
| 515 |
+
<input type="number" name="max_deduct_per_tx" min="1" max="10000" step="1" value="100">
|
| 516 |
</label>
|
| 517 |
<label class="field">
|
| 518 |
+
<span class="field-label" data-admin-plugin-text="plugin_day_label">日扣款上限</span>
|
| 519 |
+
<input type="number" name="max_deduct_per_day" min="1" max="100000" step="1" value="1000">
|
| 520 |
</label>
|
| 521 |
</div>
|
| 522 |
|
| 523 |
<fieldset class="field perm-field">
|
| 524 |
+
<legend class="field-label" data-admin-plugin-text="plugin_permissions">权限</legend>
|
| 525 |
<div class="perm-grid">
|
| 526 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="verify" checked><span data-admin-plugin-text="plugin_perm_verify">验证 Key</span></label>
|
| 527 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="balance" checked><span data-admin-plugin-text="plugin_perm_balance">查询余额</span></label>
|
| 528 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="deduct" checked><span data-admin-plugin-text="plugin_perm_deduct">扣除积分</span></label>
|
| 529 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span data-admin-plugin-text="plugin_perm_refund">退款回滚</span></label>
|
| 530 |
</div>
|
| 531 |
</fieldset>
|
| 532 |
|
|
|
|
| 533 |
</form>
|
| 534 |
<footer class="modal-footer">
|
| 535 |
+
<button type="button" class="btn btn-ghost" data-close="modal-create" data-admin-plugin-text="plugin_cancel">取消</button>
|
| 536 |
<button type="submit" form="form-create" id="btn-create-submit" class="btn btn-primary">
|
| 537 |
+
<span class="btn-label" data-admin-plugin-text="plugin_create_submit">创建插件</span>
|
| 538 |
<span class="btn-spinner" aria-hidden="true"></span>
|
| 539 |
</button>
|
| 540 |
</footer>
|
|
|
|
| 545 |
<div id="modal-edit" class="modal-mask" hidden>
|
| 546 |
<div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
|
| 547 |
<header class="modal-header">
|
| 548 |
+
<h3 id="modal-edit-title" data-admin-plugin-text="plugin_edit_title">编辑插件</h3>
|
| 549 |
+
<button type="button" class="modal-close" data-close="modal-edit" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
|
| 550 |
</header>
|
| 551 |
<form id="form-edit" class="modal-body" novalidate>
|
| 552 |
+
<div class="form-error" id="form-edit-error" role="alert" aria-atomic="true" hidden></div>
|
| 553 |
<div class="grid-2">
|
| 554 |
<label class="field">
|
| 555 |
<span class="field-label">plugin_id</span>
|
| 556 |
<input type="text" name="plugin_id" disabled>
|
| 557 |
+
<span class="field-hint" data-admin-plugin-text="plugin_id_fixed">不可修改。</span>
|
| 558 |
</label>
|
| 559 |
<label class="field">
|
| 560 |
+
<span class="field-label"><span data-admin-plugin-text="plugin_name">名称</span> <span class="required">*</span></span>
|
| 561 |
<input type="text" name="name" required>
|
| 562 |
</label>
|
| 563 |
</div>
|
| 564 |
|
| 565 |
<label class="field">
|
| 566 |
+
<span class="field-label" data-admin-plugin-text="plugin_description">描述</span>
|
| 567 |
<textarea name="description" rows="2"></textarea>
|
| 568 |
</label>
|
| 569 |
|
| 570 |
<div class="grid-3">
|
| 571 |
<label class="field">
|
| 572 |
+
<span class="field-label" data-admin-plugin-text="plugin_rate_label">每分钟限流</span>
|
| 573 |
+
<input type="number" name="rate_limit" min="1" max="10000" step="1">
|
| 574 |
</label>
|
| 575 |
<label class="field">
|
| 576 |
+
<span class="field-label" data-admin-plugin-text="plugin_tx_label">单笔扣款上限</span>
|
| 577 |
+
<input type="number" name="max_deduct_per_tx" min="1" max="10000" step="1">
|
| 578 |
</label>
|
| 579 |
<label class="field">
|
| 580 |
+
<span class="field-label" data-admin-plugin-text="plugin_day_label">日扣款上限</span>
|
| 581 |
+
<input type="number" name="max_deduct_per_day" min="1" max="100000" step="1">
|
| 582 |
</label>
|
| 583 |
</div>
|
| 584 |
|
| 585 |
<fieldset class="field perm-field">
|
| 586 |
+
<legend class="field-label" data-admin-plugin-text="plugin_permissions">权限</legend>
|
| 587 |
<div class="perm-grid">
|
| 588 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="verify"><span data-admin-plugin-text="plugin_perm_verify">验证 Key</span></label>
|
| 589 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="balance"><span data-admin-plugin-text="plugin_perm_balance">查询余额</span></label>
|
| 590 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="deduct"><span data-admin-plugin-text="plugin_perm_deduct">扣除积分</span></label>
|
| 591 |
+
<label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span data-admin-plugin-text="plugin_perm_refund">退款回滚</span></label>
|
| 592 |
</div>
|
| 593 |
</fieldset>
|
| 594 |
|
| 595 |
<div class="field">
|
| 596 |
<span class="field-label">plugin_key</span>
|
| 597 |
<div class="static-key" id="edit-key-display">—</div>
|
| 598 |
+
<span class="field-hint" data-admin-plugin-text="plugin_key_hidden_hint">出于安全考虑,完整 Key 已隐藏。如需更换请使用「轮换 Key」。</span>
|
| 599 |
</div>
|
| 600 |
|
|
|
|
| 601 |
</form>
|
| 602 |
<footer class="modal-footer">
|
| 603 |
+
<button type="button" class="btn btn-ghost" data-close="modal-edit" data-admin-plugin-text="plugin_cancel">取消</button>
|
| 604 |
<button type="submit" form="form-edit" id="btn-edit-submit" class="btn btn-primary">
|
| 605 |
+
<span class="btn-label" data-admin-plugin-text="plugin_edit_submit">保存修改</span>
|
| 606 |
<span class="btn-spinner" aria-hidden="true"></span>
|
| 607 |
</button>
|
| 608 |
</footer>
|
|
|
|
| 613 |
<div id="modal-key" class="modal-mask" hidden>
|
| 614 |
<div class="modal modal-md" role="dialog" aria-modal="true" aria-labelledby="modal-key-title">
|
| 615 |
<header class="modal-header">
|
| 616 |
+
<h3 id="modal-key-title" data-admin-plugin-text="plugin_key_title">插件 Key 已生成</h3>
|
| 617 |
+
<button type="button" class="modal-close" data-close="modal-key" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
|
| 618 |
</header>
|
| 619 |
<div class="modal-body">
|
| 620 |
<div class="key-callout">
|
| 621 |
<div class="key-callout-label">PLUGIN KEY</div>
|
| 622 |
<div class="key-callout-value" id="key-display">—</div>
|
| 623 |
<button type="button" id="btn-copy-key" class="btn btn-secondary btn-copy">
|
| 624 |
+
<span class="btn-label" data-admin-plugin-text="copy_key">复制 Key</span>
|
| 625 |
</button>
|
| 626 |
</div>
|
| 627 |
<div class="key-warn">
|
| 628 |
+
<strong data-admin-plugin-text="plugin_key_save_title">请妥善保存此 Key</strong>
|
| 629 |
+
<span data-admin-plugin-text="plugin_key_save_hint">关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。</span>
|
| 630 |
</div>
|
| 631 |
</div>
|
| 632 |
<footer class="modal-footer">
|
| 633 |
+
<button type="button" class="btn btn-primary" data-close="modal-key" data-admin-plugin-text="plugin_key_done">我已保存</button>
|
| 634 |
</footer>
|
| 635 |
</div>
|
| 636 |
</div>
|
|
|
|
| 687 |
</div>
|
| 688 |
<span class="badge js-status">—</span>
|
| 689 |
</header>
|
| 690 |
+
<p class="plugin-desc js-desc" data-admin-plugin-empty="plugin_description_empty" data-empty-description="暂无描述"></p>
|
| 691 |
<div class="plugin-key-row">
|
| 692 |
<span class="key-label">KEY</span>
|
| 693 |
<code class="js-key">—</code>
|
| 694 |
</div>
|
| 695 |
<dl class="plugin-meta">
|
| 696 |
+
<div><dt data-admin-plugin-text="plugin_rate">限流</dt><dd><span class="js-rate">—</span> <em data-admin-plugin-text="plugin_rate_unit">次/分钟</em></dd></div>
|
| 697 |
+
<div><dt data-admin-plugin-text="plugin_tx_limit">单笔限额</dt><dd class="js-tx">—</dd></div>
|
| 698 |
+
<div><dt data-admin-plugin-text="plugin_day_limit">日限额</dt><dd class="js-day">—</dd></div>
|
| 699 |
+
<div><dt data-admin-plugin-text="plugin_permissions">权限</dt><dd class="js-perms">—</dd></div>
|
| 700 |
+
<div class="plugin-meta-full"><dt data-admin-plugin-text="plugin_created">创建时间</dt><dd class="js-created">—</dd></div>
|
| 701 |
</dl>
|
| 702 |
<footer class="plugin-card-foot">
|
| 703 |
+
<button type="button" class="btn btn-ghost btn-sm" data-act="edit" data-admin-plugin-text="plugin_edit">编辑</button>
|
| 704 |
+
<button type="button" class="btn btn-ghost btn-sm" data-act="rotate" data-admin-plugin-text="plugin_rotate">轮换 Key</button>
|
| 705 |
+
<button type="button" class="btn btn-ghost btn-sm js-toggle" data-act="toggle" data-admin-plugin-text="plugin_enable">启用</button>
|
| 706 |
+
<button type="button" class="btn btn-ghost btn-sm btn-danger-text" data-act="delete" data-admin-plugin-text="plugin_delete">删除</button>
|
| 707 |
</footer>
|
| 708 |
</article>
|
| 709 |
</template>
|
管理界面/style.css
CHANGED
|
@@ -647,7 +647,7 @@ code, pre { font-family: var(--font-mono); }
|
|
| 647 |
min-height: 1.55em;
|
| 648 |
}
|
| 649 |
.plugin-desc:empty::before {
|
| 650 |
-
content:
|
| 651 |
color: var(--text-dim);
|
| 652 |
font-style: italic;
|
| 653 |
}
|
|
@@ -800,6 +800,8 @@ code, pre { font-family: var(--font-mono); }
|
|
| 800 |
font-size: 14px;
|
| 801 |
color: var(--text-soft);
|
| 802 |
line-height: 1.6;
|
|
|
|
|
|
|
| 803 |
}
|
| 804 |
|
| 805 |
/* ---------- Key 高亮展示 ---------- */
|
|
|
|
| 647 |
min-height: 1.55em;
|
| 648 |
}
|
| 649 |
.plugin-desc:empty::before {
|
| 650 |
+
content: attr(data-empty-description);
|
| 651 |
color: var(--text-dim);
|
| 652 |
font-style: italic;
|
| 653 |
}
|
|
|
|
| 800 |
font-size: 14px;
|
| 801 |
color: var(--text-soft);
|
| 802 |
line-height: 1.6;
|
| 803 |
+
white-space: pre-wrap;
|
| 804 |
+
overflow-wrap: anywhere;
|
| 805 |
}
|
| 806 |
|
| 807 |
/* ---------- Key 高亮展示 ---------- */
|