diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fd39813..3ac9dda3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ ### 破坏性变更 -- 森空岛获取凭据改为扫码登录,完善游戏社区签到、云游戏时长判断与日常便笺展示 +- 森空岛获取凭据改为扫码登录,完善游戏社区签到、云游戏时长判断与日常便笺展示 by [@qiyinxi](https://github.com/qiyinxi) - MFW 项目新增了自动更新时机设置,升级后**已有脚本一律默认为「运行前更新」**;不需要的话在项目配置里改成「不更新」 by [@qiyinxi](https://github.com/qiyinxi) - HSR 专项脚本页的「游戏启动参数」已移除,**旧配置中的该项会在下次保存时自动清除**;窗口大小改由「1920×1080 窗口模式」开关控制 by [@qiyinxi](https://github.com/qiyinxi) - MAA 专项代理接管配置时**会强制开启「开始唤醒」的账号切换开关**,以确保按用户配置的账号切号;用户未填写账号时仍不会切号 by [@qiyinxi](https://github.com/qiyinxi) @@ -69,6 +69,7 @@ ### 变更 - 启动界面 启动与初始化时的等待画面重做,只显示当前在做什么和一条进度条,首次安装或更新时才展示步骤进度,出错时给出一句话原因和一个主要操作、日志收进「详细信息」,并取消失败后的 60 秒自动重试 by [@qiyinxi](https://github.com/qiyinxi) +- 配置分享 通用脚本的模板浏览与分享改用新的 AUTO-MAS 配置中心,分享前需在浏览器完成一次登录授权,分享者身份由登录账号确定、不再手填作者 - OK-WW专项 清理无效的游戏启动选项与空配置项,避免脚本配置中出现无效设置 by [@1w1w11w1](https://github.com/1w1w11w1) by [@qiyinxi](https://github.com/qiyinxi) - 后端更新自动在后台下载并于下次启动生效,启动失败时可修复依赖后重试 by [@ClozyA](https://github.com/ClozyA) by [@qiyinxi](https://github.com/qiyinxi) - 配置来源 MAA、SRC、MaaEnd 与 OK-NTE 用户页的「简洁/详细」配置模式更名为「脚本/用户」,含义不变,旧配置自动迁移 by [@1w1w11w1](https://github.com/1w1w11w1) by [@qiyinxi](https://github.com/qiyinxi) @@ -157,7 +158,7 @@ - MFW专项 修复脚本侧强制停止任务(如 MaaEnd 分辨率不达标时的强停)被记成「任务完成」的问题:现在被强停的任务按失败记录,本轮剩余任务不再投递,不会再出现整轮一件事没做却报成全部成功 by [@qiyinxi](https://github.com/qiyinxi) - MAA专项 修复 MAA 卡死后不会被判定为超时、任务一直挂着的问题:MAA 每隔一段时间输出的日志停滞提示被当成任务仍在推进,把超时计时反复重置,剿灭等超时阈值长于提示间隔的模式永远等不到超时;现已识别新旧两版 MAA 的该提示,MAA 的五种界面语言均生效 by [@qiyinxi](https://github.com/qiyinxi) - MAA专项 修复关闭理智作战后活动关优先任务一并从任务队列中消失的问题 by [@1w1w11w1](https://github.com/1w1w11w1) by [@qiyinxi](https://github.com/qiyinxi) -- BetterGI专项、ZZZ-OD专项 修复任务出错时提示送不到前端、用户看到的报错与实际出错原因无关的问题 +- BetterGI专项、ZZZ-OD专项 修复任务出错时提示送不到前端、用户看到的报错与实际出错原因无关的问题 by [@qiyinxi](https://github.com/qiyinxi) ### 开发流程 diff --git a/app/api/__init__.py b/app/api/__init__.py index 0bf4af076..dc8ac2065 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -35,6 +35,7 @@ from .queue import router as queue_router from .scripts import router as scripts_router from .setting import router as setting_router +from .share import router as share_router from .skland_qr import router as skland_qr_router from .tools import router as tools_router from .update import router as update_router @@ -51,6 +52,7 @@ "history_router", "tools_router", "setting_router", + "share_router", "update_router", "ocr_router", "openclaw_qq_router", diff --git a/app/api/info.py b/app/api/info.py index b3c9dd96d..80d5865cc 100644 --- a/app/api/info.py +++ b/app/api/info.py @@ -231,24 +231,6 @@ async def confirm_notice() -> OutBase: # return InfoOut(data=data) -@router.post( - "/webconfig", - tags=["Get"], - summary="获取配置分享中心的配置信息", - response_model=InfoOut, - status_code=200, -) -async def get_web_config() -> InfoOut: - - try: - data = await Config.get_web_config() - except Exception as e: - return InfoOut( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={} - ) - return InfoOut(data={"WebConfig": data}) - - @router.post( "/get/overview", tags=["Get"], diff --git a/app/api/scripts.py b/app/api/scripts.py index 89b3282cf..8a16d5677 100644 --- a/app/api/scripts.py +++ b/app/api/scripts.py @@ -36,6 +36,7 @@ from app.models.config import MaaFWConfig as RuntimeMaaFWConfig from app.models.config import OkNteConfig as RuntimeOkNteConfig from app.models.schema import * +from app.services import ConfigCenterError from app.task.MaaFW.tools.core.automas_maafw_interface.loader import ( MaaFWInterfaceLoadError, load_interface_model_cached, @@ -396,14 +397,18 @@ async def export_script_to_file(script: ScriptFileIn = Body(...)) -> OutBase: @router.post( "/import/web", tags=["Update"], - summary="从网络加载脚本配置", + summary="从配置中心导入脚本配置", response_model=OutBase, status_code=200, ) -async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase: +async def import_script_from_web(script: ScriptTemplateImportIn = Body(...)) -> OutBase: try: - await Config.import_script_from_web(script.scriptId, script.url) + await Config.import_script_from_share( + script.scriptId, config_key=script.configKey, version_no=script.versionNo + ) + except ConfigCenterError as e: + return OutBase(code=500, status="error", message=str(e)) except Exception as e: return OutBase( code=500, status="error", message=f"{type(e).__name__}: {str(e)}" @@ -411,19 +416,46 @@ async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase: return OutBase() +@router.post( + "/share/inspect", + tags=["Get"], + summary="分享前检查脚本配置中的隐私风险", + response_model=ShareInspectOut, + status_code=200, +) +async def inspect_script_share( + script: ScriptShareInspectIn = Body(...), +) -> ShareInspectOut: + + try: + _, risks = await Config.build_share_config( + script.scriptId, config_name=script.config_name + ) + except Exception as e: + return ShareInspectOut( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return ShareInspectOut(risks=[ShareRiskItem(**_) for _ in risks]) + + @router.post( "/Upload/web", tags=["Action"], - summary="上传脚本配置到网络", + summary="分享脚本配置到配置中心", response_model=OutBase, status_code=200, ) async def upload_script_to_web(script: ScriptUploadIn = Body(...)) -> OutBase: try: - await Config.upload_script_to_web( - script.scriptId, script.config_name, script.author, script.description + await Config.upload_script_to_share( + script.scriptId, + config_name=script.config_name, + description=script.description, + acknowledged=script.acknowledged, ) + except ConfigCenterError as e: + return OutBase(code=500, status="error", message=str(e)) except Exception as e: return OutBase( code=500, status="error", message=f"{type(e).__name__}: {str(e)}" diff --git a/app/api/share.py b/app/api/share.py new file mode 100644 index 000000000..fab82da9b --- /dev/null +++ b/app/api/share.py @@ -0,0 +1,158 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +from typing import Any, Dict + +from fastapi import APIRouter, Body + +from app.models.schema import ( + ShareAuthStatusOut, + ShareTemplateItem, + ShareTemplateListIn, + ShareTemplateListOut, +) +from app.services import ConfigCenter, ConfigCenterError + +router = APIRouter(prefix="/api/share", tags=["配置中心"]) + + +def _build_auth_status(status: Dict[str, Any]) -> ShareAuthStatusOut: + """把配置中心客户端的状态字典映射成响应模型""" + + return ShareAuthStatusOut( + message=status.get("message", "操作成功"), + authStatus=status.get("status", "idle"), + username=status.get("username", ""), + displayName=status.get("displayName", ""), + userCode=status.get("userCode", ""), + verificationUri=status.get("verificationUri", ""), + expiresIn=status.get("expiresIn", 0), + interval=status.get("interval", 5), + ) + + +@router.post( + "/templates", + tags=["Get"], + summary="获取配置中心已发布的通用脚本配置", + response_model=ShareTemplateListOut, + status_code=200, +) +async def list_share_templates( + query: ShareTemplateListIn = Body(...), +) -> ShareTemplateListOut: + + try: + items, pagination = await ConfigCenter.list_templates( + query.page, query.pageSize, query.keyword + ) + except ConfigCenterError as e: + return ShareTemplateListOut(code=500, status="error", message=str(e)) + except Exception as e: + return ShareTemplateListOut( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + + return ShareTemplateListOut( + items=[ShareTemplateItem(**_) for _ in items], + page=pagination["page"], + pageSize=pagination["pageSize"], + total=pagination["total"], + hasNext=pagination["hasNext"], + ) + + +@router.post( + "/auth/status", + tags=["Get"], + summary="获取配置中心授权状态", + response_model=ShareAuthStatusOut, + status_code=200, +) +async def get_share_auth_status() -> ShareAuthStatusOut: + + return _build_auth_status(ConfigCenter.get_status()) + + +@router.post( + "/auth/start", + tags=["Action"], + summary="发起配置中心浏览器授权", + response_model=ShareAuthStatusOut, + status_code=200, +) +async def start_share_auth() -> ShareAuthStatusOut: + + try: + data = await ConfigCenter.start_authorization() + except ConfigCenterError as e: + return ShareAuthStatusOut( + code=500, status="error", message=str(e), authStatus="idle" + ) + except Exception as e: + return ShareAuthStatusOut( + code=500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + authStatus="idle", + ) + + return _build_auth_status({"status": "pending", **data}) + + +@router.post( + "/auth/poll", + tags=["Get"], + summary="轮询配置中心授权结果", + response_model=ShareAuthStatusOut, + status_code=200, +) +async def poll_share_auth() -> ShareAuthStatusOut: + + try: + status = await ConfigCenter.poll_authorization() + except ConfigCenterError as e: + return ShareAuthStatusOut( + code=500, status="error", message=str(e), authStatus="idle" + ) + except Exception as e: + return ShareAuthStatusOut( + code=500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + authStatus="idle", + ) + + return _build_auth_status(status) + + +@router.post( + "/auth/cancel", + tags=["Action"], + summary="取消等待中的配置中心授权", + response_model=ShareAuthStatusOut, + status_code=200, +) +async def cancel_share_auth() -> ShareAuthStatusOut: + + await ConfigCenter.cancel_authorization() + return _build_auth_status(ConfigCenter.get_status()) diff --git a/app/core/config.py b/app/core/config.py index 226ee1989..6707f614b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -954,10 +954,25 @@ async def export_script_to_file(self, script_id: str, jsonFile: str): logger.success(f"{script_id} 配置导出成功") - async def import_script_from_web(self, script_id: str, url: str): - """从「AUTO-MAS 配置分享中心」导入配置""" + async def import_script_from_share( + self, script_id: str, config_key: str, version_no: Optional[int] + ) -> None: + """从「AUTO-MAS 配置中心」导入通用脚本配置。 + + Args: + script_id: 目标通用脚本ID。 + config_key: 配置中心的配置标识。 + version_no: 版本号, 为空表示已发布的最新版本。 + + Raises: + KeyError: 脚本不存在。 + TypeError: 脚本不是通用脚本配置。 + ConfigCenterError: 下载失败或内容不是通用脚本配置。 + """ - logger.info(f"从网络加载脚本配置: {script_id} - {url}") + from app.services import ConfigCenter + + logger.info(f"从配置中心加载脚本配置: {script_id} - {config_key}") uid = uuid.UUID(script_id) if uid not in self.ScriptConfig: @@ -967,41 +982,30 @@ async def import_script_from_web(self, script_id: str, url: str): logger.error(f"{script_id} 不是通用脚本配置") raise TypeError(f"脚本 {script_id} 不是通用脚本配置") - # 使用 httpx 异步请求 - async with httpx.AsyncClient( - proxy=Config.proxy, follow_redirects=True - ) as client: - try: - response = await client.get(url) - if response.status_code == 200: - data = response.json() - else: - logger.warning( - f"无法从 AUTO-MAS 服务器获取配置内容: {response.text}" - ) - raise ConnectionError( - f"无法从 AUTO-MAS 服务器获取配置内容: {response.status_code}" - ) - except httpx.RequestError as e: - logger.warning(f"无法从 AUTO-MAS 服务器获取配置内容: {e}") - raise ConnectionError(f"无法从 AUTO-MAS 服务器获取配置内容: {e}") - - if data.get("code", 200) == 500: - logger.error(f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}") - raise ConnectionError( - f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}" - ) - + data = await ConfigCenter.download_template(config_key, version_no) await self.ScriptConfig[uid].load(data) logger.success(f"{script_id} 配置加载成功") - async def upload_script_to_web( - self, script_id: str, config_name: str, author: str, description: str - ): - """上传配置到「AUTO-MAS 配置分享中心」""" + async def build_share_config( + self, script_id: str, config_name: str + ) -> tuple[dict, List[Dict[str, str]]]: + """整理待分享的通用脚本配置。 + + 用户数据(SubConfigsInfo)整体丢弃, 路径类配置项做占位替换, 剩下仍然可疑的内容 + 以风险项返回, 由用户确认后才允许上传。 - logger.info(f"上传配置到网络: {script_id} - {config_name} - {author}") + Args: + script_id: 目标通用脚本ID。 + config_name: 分享时使用的配置名称。 + + Returns: + 脱敏后的配置字典, 以及仍需用户确认的风险项列表。 + + Raises: + KeyError: 脚本不存在。 + TypeError: 脚本不是通用脚本配置。 + """ uid = uuid.UUID(script_id) @@ -1016,35 +1020,33 @@ async def upload_script_to_web( temp.pop("SubConfigsInfo", None) temp = await self.remove_privacy_info(temp, config_name) - files = { - "file": ( - f"{config_name}&&{int(datetime.now(tz=UTC8).timestamp() * 1000)}.json", - json.dumps(temp, ensure_ascii=False), - "application/json", - ) - } - data = {"username": author, "description": description} + return temp, self.scan_privacy_risks(temp) - async with httpx.AsyncClient( - proxy=Config.proxy, follow_redirects=True - ) as client: - try: - response = await client.post( - "https://share.auto-mas.top/api/upload/share", - files=files, - data=data, - ) + async def upload_script_to_share( + self, script_id: str, config_name: str, description: str, acknowledged: bool + ) -> None: + """以当前授权用户的身份把配置提交到「AUTO-MAS 配置中心」等待审核。 - if response.status_code == 200: - logger.success("配置上传成功") - else: - logger.error(f"无法上传配置到 AUTO-MAS 服务器: {response.text}") - raise ConnectionError( - f"无法上传配置到 AUTO-MAS 服务器: {response.status_code} - {response.text}" - ) - except httpx.RequestError as e: - logger.error(f"无法上传配置到 AUTO-MAS 服务器: {e}") - raise ConnectionError(f"无法上传配置到 AUTO-MAS 服务器: {e}") + Args: + script_id: 目标通用脚本ID。 + config_name: 配置名称。 + description: 配置描述。 + acknowledged: 用户是否已确认分享前检查出的风险项。 + + Raises: + ConfigCenterError: 未授权、存在未确认的风险项或上传被拒绝。 + """ + + from app.services import ConfigCenter, ConfigCenterError + + logger.info(f"上传配置到配置中心: {script_id} - {config_name}") + + config, risks = await self.build_share_config(script_id, config_name) + if risks and not acknowledged: + logger.warning(f"分享前检查到 {len(risks)} 项待确认内容, 已阻止上传") + raise ConfigCenterError("配置中仍有可能泄露隐私的内容, 请确认后再分享") + + await ConfigCenter.upload_config(config_name, description, config) async def remove_privacy_info(self, config: dict, name: str) -> dict: """移除配置中可能存在的隐私信息""" @@ -1068,8 +1070,83 @@ async def remove_privacy_info(self, config: dict, name: str) -> dict: ) config["Info"]["RootPath"] = str(Path(r"C:/脚本根目录")) + # 上面只覆盖脚本自身的路径项;游戏路径、命令行等自由文本同样会带出本机用户名,统一打码 + for items in config.values(): + if not isinstance(items, dict): + continue + for key, value in items.items(): + if isinstance(value, str) and value: + items[key] = self._mask_home_path(value) + return config + @staticmethod + def _mask_home_path(value: str) -> str: + """把值里出现的本机用户目录替换成占位符""" + + home = str(Path.home()) + if not home: + return value + + masked = value + for candidate in {home, home.replace("\\", "/")}: + # 要求用户目录后面是分隔符或行尾, 否则 C:\Users\qiyin 会切掉 C:\Users\qiyinxi 的一截 + masked = re.sub( + rf"{re.escape(candidate)}(?=[\\/]|$)", + "%USERPROFILE%", + masked, + flags=re.IGNORECASE, + ) + return masked + + def scan_privacy_risks(self, config: dict) -> List[Dict[str, str]]: + """检查脱敏后的配置里是否还残留不该分享的内容。 + + 自动脱敏只能处理已知的路径项, 命令行参数、日志规则这类自由文本仍可能带出账号、 + 密码、令牌或本机绝对路径, 这里把它们挑出来交给用户确认。 + + Args: + config: 已经过 remove_privacy_info 处理的配置字典。 + + Returns: + 风险项列表, 每项包含配置项名称与风险说明。 + """ + + placeholders = ("C:\\脚本根目录", "C:/脚本根目录", "%APPDATA%", "%USERPROFILE%") + user_name = Path.home().name + risks: List[Dict[str, str]] = [] + + for group, items in config.items(): + if not isinstance(items, dict): + continue + for key, value in items.items(): + if not isinstance(value, str) or not value.strip(): + continue + + field = f"{group}.{key}" + if re.search( + r"(?i)(password|passwd|pwd|token|secret|api[_-]?key|cookie|session" + r"|密码|密钥|口令)\s*[=:\s]\s*\S", + value, + ): + risks.append({"field": field, "reason": "疑似包含账号、密码或令牌"}) + continue + if re.search(r"://[^/\s:@]+:[^/\s@]+@", value): + risks.append({"field": field, "reason": "链接中带有账号密码"}) + continue + if len(user_name) > 2 and user_name.lower() in value.lower(): + risks.append({"field": field, "reason": "包含本机用户名"}) + continue + + probe = value + for placeholder in placeholders: + probe = probe.replace(placeholder, "") + # 盘符前不能再跟字母, 否则 https:// 这类协议头会被当成 C:\ 一样的盘符路径 + if re.search(r"(? tuple[list, dict]: @@ -3613,49 +3690,6 @@ async def get_notice(self) -> tuple[bool, Dict[str, str]]: self.get("Data", "Notice") ).get("notice_dict", {}) - async def get_web_config(self): - """获取「AUTO-MAS 配置分享中心」配置""" - - local_web_config = json.loads(self.get("Data", "WebConfig")) - if datetime.now() - timedelta(hours=1) < datetime.strptime( - self.get("Data", "LastWebConfigUpdated"), "%Y-%m-%d %H:%M:%S" - ): - logger.info("一小时内已进行过一次检查, 直接使用缓存的配置分享中心信息") - return local_web_config - - logger.info("开始从 AUTO-MAS 服务器获取配置分享中心信息") - - try: - async with httpx.AsyncClient( - proxy=self.proxy, follow_redirects=True - ) as client: - response = await client.get( - "https://share.auto-mas.top/api/list/config/general" - ) - if response.status_code == 200: - remote_web_config = response.json() - else: - logger.warning( - f"无法从 AUTO-MAS 服务器获取配置分享中心信息:{response.text}" - ) - remote_web_config = None - except Exception as e: - logger.warning(f"无法从 AUTO-MAS 服务器获取配置分享中心信息: {e}") - remote_web_config = None - - if remote_web_config is None: - logger.warning("使用本地配置分享中心信息") - return local_web_config - - await self.set( - "Data", "LastWebConfigUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S") - ) - await self.set( - "Data", "WebConfig", json.dumps(remote_web_config, ensure_ascii=False) - ) - - return remote_web_config - def build_history_log_path( self, *, script_name: str, user_name: str, log_time: datetime ) -> Path: diff --git a/app/models/config.py b/app/models/config.py index 19f5ed164..dc0b2ae86 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -4380,17 +4380,6 @@ def __init__(self): ) ## 公告内容 self.Data_Notice = ConfigItem("Data", "Notice", "{ }", JSONValidator()) - ## 上次 Web 配置更新时间 - self.Data_LastWebConfigUpdated = ConfigItem( - "Data", - "LastWebConfigUpdated", - "2000-01-01 00:00:00", - DateTimeValidator("%Y-%m-%d %H:%M:%S"), - ) - ## Web 配置 - self.Data_WebConfig = ConfigItem( - "Data", "WebConfig", "[ ]", JSONValidator(list) - ) super().__init__() ## 模拟器配置列表 diff --git a/app/models/schema.py b/app/models/schema.py index 5d6c9ecd6..a937bbf54 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -3604,16 +3604,79 @@ class ScriptFileIn(BaseModel): jsonFile: str = Field(..., description="配置文件路径") -class ScriptUrlIn(BaseModel): +class ShareTemplateListIn(BaseModel): + page: int = Field(default=1, ge=1, description="页码, 从 1 开始") + pageSize: int = Field(default=20, ge=1, le=100, description="每页条数") + keyword: Optional[str] = Field(default=None, description="搜索关键字") + + +class ShareTemplateItem(BaseModel): + projectKey: str = Field(..., description="配置中心项目标识") + categoryKey: str = Field(..., description="配置中心分类标识") + configKey: str = Field(..., description="配置中心配置标识") + displayName: str = Field(..., description="配置名称") + description: str = Field(default="", description="配置描述") + ownerUsername: str = Field(default="", description="分享者用户名") + publishedVersionNo: Optional[int] = Field( + default=None, description="已发布的版本号" + ) + publishedAt: str = Field(default="", description="发布时间") + updatedAt: str = Field(default="", description="更新时间") + + +class ShareTemplateListOut(OutBase): + items: List[ShareTemplateItem] = Field( + default_factory=list, description="配置模板列表" + ) + page: int = Field(default=1, description="当前页码") + pageSize: int = Field(default=20, description="每页条数") + total: int = Field(default=0, description="模板总数") + hasNext: bool = Field(default=False, description="是否还有下一页") + + +class ScriptShareInspectIn(BaseModel): scriptId: str = Field(..., description="脚本ID") - url: str = Field(..., description="配置文件URL") + config_name: str = Field(..., min_length=1, max_length=64, description="配置名称") + + +class ScriptTemplateImportIn(BaseModel): + scriptId: str = Field(..., description="脚本ID") + configKey: str = Field(..., description="配置中心配置标识") + versionNo: Optional[int] = Field( + default=None, ge=1, description="版本号, 为空表示已发布的最新版本" + ) class ScriptUploadIn(BaseModel): scriptId: str = Field(..., description="脚本ID") - config_name: str = Field(..., description="配置名称") - author: str = Field(..., description="作者") - description: str = Field(..., description="描述") + config_name: str = Field(..., min_length=1, max_length=64, description="配置名称") + description: str = Field(..., min_length=1, max_length=500, description="描述") + acknowledged: bool = Field( + default=False, description="是否已确认分享前检查出的隐私风险项" + ) + + +class ShareRiskItem(BaseModel): + field: str = Field(..., description="存在风险的配置项") + reason: str = Field(..., description="风险说明") + + +class ShareInspectOut(OutBase): + risks: List[ShareRiskItem] = Field( + default_factory=list, description="分享前检查出的隐私风险项" + ) + + +class ShareAuthStatusOut(OutBase): + authStatus: Literal["idle", "pending", "authorized", "denied", "expired"] = Field( + ..., description="配置中心授权状态" + ) + username: str = Field(default="", description="已授权用户的用户名") + displayName: str = Field(default="", description="已授权用户的显示名") + userCode: str = Field(default="", description="待用户在浏览器确认的短授权码") + verificationUri: str = Field(default="", description="浏览器授权页地址") + expiresIn: int = Field(default=0, description="剩余有效秒数") + interval: int = Field(default=5, description="建议的轮询间隔秒数") class UserInBase(BaseModel): diff --git a/app/services/__init__.py b/app/services/__init__.py index ba7a7de45..2f5bc66a7 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -20,9 +20,10 @@ # Contact: DLmaster_361@163.com +from .config_center import ConfigCenter, ConfigCenterError from .matomo import Matomo from .notification import Notify from .system import System from .update import Updater -__all__ = ["Matomo", "Notify", "System", "Updater"] +__all__ = ["ConfigCenter", "ConfigCenterError", "Matomo", "Notify", "System", "Updater"] diff --git a/app/services/config_center.py b/app/services/config_center.py new file mode 100644 index 000000000..e209f5b43 --- /dev/null +++ b/app/services/config_center.py @@ -0,0 +1,477 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +import asyncio +import json +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from app.utils import LazyProxy, get_logger + +logger = get_logger("配置中心") + +# 延迟加载 Config,避免 app.services 初始化期间触发 app.core 循环导入 +Config = LazyProxy("app.core", "Config") + + +# ==================== 部署参数 ==================== +# 新配置中心的后端地址、以及「通用脚本」对应的 project/category,在三个仓库里都没有写死的 +# 生产值,统一收敛到这里;部署方用环境变量覆盖即可,不需要改代码。 +# 默认值沿用分享站域名加新后端的 /api/v1 前缀:新旧接口路径完全不重叠(旧站是 +# /api/list/... 与 /api/upload/...),指向旧站时只会得到明确的失败提示,不会误写旧服务。 +API_BASE_URL = os.environ.get( + "AUTO_MAS_CONFIG_CENTER_API", "https://share.auto-mas.top/api/v1" +).rstrip("/") +PROJECT_KEY = os.environ.get("AUTO_MAS_CONFIG_CENTER_PROJECT", "auto-mas") +CATEGORY_KEY = os.environ.get("AUTO_MAS_CONFIG_CENTER_CATEGORY", "general") + +# 通用脚本配置实际只有几 KiB,2 MiB 足够留出余量,同时挡住异常的超大响应 +MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 +REQUEST_TIMEOUT = 15.0 +# 桌面令牌到期前留出的余量,避免上传到一半才失效 +TOKEN_EXPIRE_MARGIN = timedelta(seconds=30) + + +class ConfigCenterError(RuntimeError): + """配置中心交互失败,message 可直接展示给用户。""" + + +class ConfigCenterClient: + """新版 AUTO-MAS 配置中心的客户端,兼管桌面端授权状态。 + + 设备码与桌面令牌只保存在内存中:不写入配置文件、不进日志、不进 URL 查询串, + 进程退出即失效。 + """ + + def __init__(self) -> None: + + ## 设备授权会话(等待用户在浏览器里确认时才有值) + self._device_code: Optional[str] = None + self._device_expires_at: Optional[datetime] = None + self._user_code: str = "" + self._verification_uri: str = "" + self._poll_interval: int = 5 + + ## 授权完成后的桌面令牌 + self._token: Optional[str] = None + self._token_expires_at: Optional[datetime] = None + self._username: str = "" + self._display_name: str = "" + + self._lock = asyncio.Lock() + + # ==================== 模板浏览 ==================== + + async def list_templates( + self, page: int, page_size: int, keyword: Optional[str] + ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """拉取已发布的通用脚本配置列表。 + + Args: + page: 页码, 从 1 开始。 + page_size: 每页条数。 + keyword: 搜索关键字, 为空表示不过滤。 + + Returns: + (配置条目列表, 分页信息)。 + """ + + params: Dict[str, Any] = { + "project_key": PROJECT_KEY, + "category_key": CATEGORY_KEY, + "page": page, + "page_size": page_size, + } + if keyword: + params["keyword"] = keyword + + data = await self._request("GET", "/configs", params=params) + items = [self._build_template_item(_) for _ in data.get("items", []) or []] + pagination = data.get("pagination", {}) or {} + + return items, { + "page": int(pagination.get("page", page)), + "pageSize": int(pagination.get("page_size", page_size)), + "total": int(pagination.get("total", len(items))), + "hasNext": bool(pagination.get("has_next", False)), + } + + async def download_template( + self, config_key: str, version_no: Optional[int] + ) -> Dict[str, Any]: + """下载指定配置的已发布版本并解析为通用脚本配置字典。 + + Args: + config_key: 配置中心的配置标识。 + version_no: 版本号, 为空表示已发布的最新版本。 + + Returns: + 解析后的配置字典。 + + Raises: + ConfigCenterError: 下载失败、体积超限或内容不是通用脚本配置。 + """ + + params = {"version_no": version_no} if version_no else None + url = ( + f"{API_BASE_URL}/configs/{PROJECT_KEY}/{CATEGORY_KEY}/{config_key}/download" + ) + + async with httpx.AsyncClient( + proxy=Config.proxy, follow_redirects=True, timeout=REQUEST_TIMEOUT + ) as client: + try: + async with client.stream("GET", url, params=params) as response: + if response.status_code != 200: + await response.aread() + raise ConfigCenterError( + self._describe_failure(response, "下载配置失败") + ) + + chunks: List[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + total += len(chunk) + if total > MAX_DOWNLOAD_BYTES: + raise ConfigCenterError( + f"配置文件超过 {MAX_DOWNLOAD_BYTES // 1024 // 1024} MB, 已终止下载" + ) + chunks.append(chunk) + except httpx.HTTPError as e: + logger.warning(f"下载配置失败: {e}") + raise ConfigCenterError(f"无法连接配置中心: {e}") from e + + try: + data = json.loads(b"".join(chunks).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise ConfigCenterError("配置文件不是有效的 JSON, 无法导入") from e + + if not isinstance(data, dict) or not isinstance(data.get("Script"), dict): + raise ConfigCenterError("配置文件不是通用脚本配置, 无法导入") + + # 分享站上的文件可能是从网页端手工上传的,里面还带着用户数据,导入时一律丢掉 + data.pop("SubConfigsInfo", None) + + return data + + # ==================== 设备授权 ==================== + + async def start_authorization(self) -> Dict[str, Any]: + """向配置中心申请设备码, 返回给前端用于引导用户到浏览器授权。""" + + async with self._lock: + data = await self._request( + "POST", "/auth/device/code", json_body={"client_name": "AUTO-MAS"} + ) + + device_code = str(data.get("device_code", "")) + if not device_code: + raise ConfigCenterError("配置中心未返回设备码") + + self._device_code = device_code + self._user_code = str(data.get("user_code", "")) + self._verification_uri = str( + data.get("verification_uri_complete") + or data.get("verification_uri") + or "" + ) + self._poll_interval = max(int(data.get("interval", 5) or 5), 1) + expires_in = max(int(data.get("expires_in", 600) or 600), 1) + self._device_expires_at = datetime.now() + timedelta(seconds=expires_in) + + logger.info(f"已申请配置中心设备授权码: {self._user_code}") + + return { + "userCode": self._user_code, + "verificationUri": self._verification_uri, + "expiresIn": expires_in, + "interval": self._poll_interval, + } + + async def poll_authorization(self) -> Dict[str, Any]: + """轮询一次授权结果。 + + Returns: + status 为 pending / authorized / denied / expired / idle 的状态字典。 + """ + + async with self._lock: + if not self._device_code: + return self._build_status() + + if ( + self._device_expires_at is not None + and datetime.now() >= self._device_expires_at + ): + self._reset_device_session() + return {"status": "expired", "message": "授权码已过期, 请重新发起授权"} + + device_code = self._device_code + + # 网络请求放在锁外:轮询最长要等一个超时,期间用户点「取消」不该被卡住 + data = await self._request( + "POST", "/auth/device/token", json_body={"device_code": device_code} + ) + + async with self._lock: + # 等待期间用户可能已取消或重新发起,本次结果就作废 + if self._device_code != device_code: + return self._build_status() + + status = str(data.get("status", "pending")) + + if status == "authorized": + self._token = str(data.get("access_token", "")) + expires_in = max(int(data.get("expires_in", 1800) or 1800), 1) + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) + self._username = str(data.get("username", "")) + self._display_name = str(data.get("display_name") or self._username) + self._reset_device_session() + logger.success(f"配置中心授权成功: {self._username}") + return self._build_status() + + if status in ("denied", "expired"): + self._reset_device_session() + message = ( + "已在浏览器中拒绝本次授权" + if status == "denied" + else "授权码已过期, 请重新发起授权" + ) + return {"status": status, "message": message} + + if status == "slow_down": + self._poll_interval = max(int(data.get("interval", 5) or 5), 1) + 1 + + return { + "status": "pending", + "userCode": self._user_code, + "verificationUri": self._verification_uri, + "interval": self._poll_interval, + } + + async def cancel_authorization(self) -> None: + """用户主动取消授权等待。""" + + async with self._lock: + if self._device_code: + logger.info("用户取消了配置中心授权") + self._reset_device_session() + + def get_status(self) -> Dict[str, Any]: + """返回当前授权状态, 供前端渲染分享弹窗。""" + + return self._build_status() + + # ==================== 上传 ==================== + + async def upload_config( + self, display_name: str, description: str, config: Dict[str, Any] + ) -> Dict[str, Any]: + """以当前登录用户的身份提交一份新配置, 进入配置中心的待审核流程。 + + Args: + display_name: 配置名称。 + description: 配置描述。 + config: 已完成脱敏的通用脚本配置字典。 + + Returns: + 配置中心返回的配置信息。 + + Raises: + ConfigCenterError: 未登录、登录已过期或上传被拒绝。 + """ + + token = self._require_token() + content = json.dumps(config, ensure_ascii=False).encode("utf-8") + + data = await self._request( + "POST", + "/user/configs", + token=token, + files={"file": (f"{display_name}.json", content, "application/json")}, + data={ + "project_key": PROJECT_KEY, + "category_key": CATEGORY_KEY, + "display_name": display_name, + "description": description, + "change_note": "来自 AUTO-MAS 桌面端分享", + }, + ) + + logger.success(f"配置已提交配置中心待审核: {display_name}") + return data + + # ==================== 内部实现 ==================== + + def _require_token(self) -> str: + """取出仍然有效的桌面令牌。""" + + if not self._token or self._token_expires_at is None: + raise ConfigCenterError("尚未登录配置中心, 请先完成浏览器授权") + if datetime.now() + TOKEN_EXPIRE_MARGIN >= self._token_expires_at: + self._clear_token() + raise ConfigCenterError("配置中心登录状态已过期, 请重新授权") + return self._token + + def _clear_token(self) -> None: + """丢弃桌面令牌与登录用户信息。""" + + self._token = None + self._token_expires_at = None + self._username = "" + self._display_name = "" + + def _build_status(self) -> Dict[str, Any]: + """把内存中的授权状态整理成前端可直接使用的形状。""" + + if self._token and self._token_expires_at is not None: + if datetime.now() + TOKEN_EXPIRE_MARGIN < self._token_expires_at: + return { + "status": "authorized", + "username": self._username, + "displayName": self._display_name, + "expiresIn": int( + (self._token_expires_at - datetime.now()).total_seconds() + ), + } + self._clear_token() + + if self._device_code: + return { + "status": "pending", + "userCode": self._user_code, + "verificationUri": self._verification_uri, + "interval": self._poll_interval, + } + + return {"status": "idle"} + + def _reset_device_session(self) -> None: + """清空未完成的设备授权会话。""" + + self._device_code = None + self._device_expires_at = None + self._user_code = "" + self._verification_uri = "" + + def _build_template_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + """把配置中心的列表项转成前端使用的形状。 + + 名称、描述、作者都是外部数据, 这里只做类型收敛, 渲染侧按纯文本处理。 + """ + + return { + "projectKey": str(item.get("project_key", PROJECT_KEY)), + "categoryKey": str(item.get("category_key", CATEGORY_KEY)), + "configKey": str(item.get("config_key", "")), + "displayName": str(item.get("display_name", "")), + "description": str(item.get("description") or ""), + "ownerUsername": str(item.get("owner_username") or ""), + "publishedVersionNo": item.get("published_version_no"), + "publishedAt": str(item.get("published_at") or ""), + "updatedAt": str(item.get("updated_at") or ""), + } + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + token: Optional[str] = None, + ) -> Dict[str, Any]: + """调用配置中心接口并拆掉 {code, message, data} 信封。 + + Raises: + ConfigCenterError: 网络失败或配置中心返回错误。 + """ + + headers = {"Authorization": f"Bearer {token}"} if token else None + + async with httpx.AsyncClient( + proxy=Config.proxy, follow_redirects=True, timeout=REQUEST_TIMEOUT + ) as client: + try: + response = await client.request( + method, + f"{API_BASE_URL}{path}", + params=params, + json=json_body, + data=data, + files=files, + headers=headers, + ) + except httpx.HTTPError as e: + logger.warning(f"请求配置中心失败: {method} {path} - {e}") + raise ConfigCenterError(f"无法连接配置中心: {e}") from e + + if response.status_code >= 400: + # 服务端说令牌不认了就别再留着,否则界面会一直显示已登录 + if response.status_code == 401 and token: + self._clear_token() + raise ConfigCenterError( + self._describe_failure(response, "配置中心请求失败") + ) + + try: + payload = response.json() + except ValueError as e: + raise ConfigCenterError("配置中心返回了无法解析的内容") from e + + if not isinstance(payload, dict): + raise ConfigCenterError("配置中心返回了无法解析的内容") + + result = payload.get("data") + return result if isinstance(result, dict) else {} + + @staticmethod + def _describe_failure(response: httpx.Response, fallback: str) -> str: + """把配置中心的错误响应整理成一句可展示的中文提示。""" + + message = "" + try: + payload = response.json() + if isinstance(payload, dict): + message = str(payload.get("message") or "") + except ValueError: + message = "" + + if response.status_code == 401: + return "配置中心登录状态已失效, 请重新授权" + if response.status_code == 409: + # 这个端点上的 409 只可能是同名配置已存在,服务端消息是英文的,直接给中文提示 + return "该配置名称已被占用, 请换一个名称" + if response.status_code == 413: + return message or "配置文件超过配置中心的体积上限" + if response.status_code == 429: + return message or "操作过于频繁, 请稍后再试" + + return f"{fallback}: {message or response.status_code}" + + +ConfigCenter = ConfigCenterClient() diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index fe30f1685..e8d666da2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -310,11 +310,18 @@ export type { ScriptGetIn } from './models/ScriptGetIn'; export type { ScriptGetOut } from './models/ScriptGetOut'; export { ScriptIndexItem } from './models/ScriptIndexItem'; export type { ScriptReorderIn } from './models/ScriptReorderIn'; +export type { ScriptShareInspectIn } from './models/ScriptShareInspectIn'; +export type { ScriptTemplateImportIn } from './models/ScriptTemplateImportIn'; export type { ScriptUpdateIn } from './models/ScriptUpdateIn'; export type { ScriptUploadIn } from './models/ScriptUploadIn'; -export type { ScriptUrlIn } from './models/ScriptUrlIn'; export type { SettingGetOut } from './models/SettingGetOut'; export type { SettingUpdateIn } from './models/SettingUpdateIn'; +export { ShareAuthStatusOut } from './models/ShareAuthStatusOut'; +export type { ShareInspectOut } from './models/ShareInspectOut'; +export type { ShareRiskItem } from './models/ShareRiskItem'; +export type { ShareTemplateItem } from './models/ShareTemplateItem'; +export type { ShareTemplateListIn } from './models/ShareTemplateListIn'; +export type { ShareTemplateListOut } from './models/ShareTemplateListOut'; export type { SklandQrCheckIn } from './models/SklandQrCheckIn'; export type { SklandQrCheckOut } from './models/SklandQrCheckOut'; export type { SklandQrCreateOut } from './models/SklandQrCreateOut'; diff --git a/frontend/src/api/models/ScriptUrlIn.ts b/frontend/src/api/models/ScriptShareInspectIn.ts similarity index 71% rename from frontend/src/api/models/ScriptUrlIn.ts rename to frontend/src/api/models/ScriptShareInspectIn.ts index 2d98450de..321ae6f44 100644 --- a/frontend/src/api/models/ScriptUrlIn.ts +++ b/frontend/src/api/models/ScriptShareInspectIn.ts @@ -2,14 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type ScriptUrlIn = { +export type ScriptShareInspectIn = { /** * 脚本ID */ scriptId: string; /** - * 配置文件URL + * 配置名称 */ - url: string; + config_name: string; }; diff --git a/frontend/src/api/models/ScriptTemplateImportIn.ts b/frontend/src/api/models/ScriptTemplateImportIn.ts new file mode 100644 index 000000000..ab0626d27 --- /dev/null +++ b/frontend/src/api/models/ScriptTemplateImportIn.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ScriptTemplateImportIn = { + /** + * 脚本ID + */ + scriptId: string; + /** + * 配置中心配置标识 + */ + configKey: string; + /** + * 版本号, 为空表示已发布的最新版本 + */ + versionNo?: (number | null); +}; + diff --git a/frontend/src/api/models/ScriptUploadIn.ts b/frontend/src/api/models/ScriptUploadIn.ts index eb28bdb45..69dbd39c8 100644 --- a/frontend/src/api/models/ScriptUploadIn.ts +++ b/frontend/src/api/models/ScriptUploadIn.ts @@ -11,13 +11,13 @@ export type ScriptUploadIn = { * 配置名称 */ config_name: string; - /** - * 作者 - */ - author: string; /** * 描述 */ description: string; + /** + * 是否已确认分享前检查出的隐私风险项 + */ + acknowledged?: boolean; }; diff --git a/frontend/src/api/models/ShareAuthStatusOut.ts b/frontend/src/api/models/ShareAuthStatusOut.ts new file mode 100644 index 000000000..76e5a6fe8 --- /dev/null +++ b/frontend/src/api/models/ShareAuthStatusOut.ts @@ -0,0 +1,59 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ShareAuthStatusOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 配置中心授权状态 + */ + authStatus: ShareAuthStatusOut.authStatus; + /** + * 已授权用户的用户名 + */ + username?: string; + /** + * 已授权用户的显示名 + */ + displayName?: string; + /** + * 待用户在浏览器确认的短授权码 + */ + userCode?: string; + /** + * 浏览器授权页地址 + */ + verificationUri?: string; + /** + * 剩余有效秒数 + */ + expiresIn?: number; + /** + * 建议的轮询间隔秒数 + */ + interval?: number; +}; +export namespace ShareAuthStatusOut { + /** + * 配置中心授权状态 + */ + export enum authStatus { + IDLE = 'idle', + PENDING = 'pending', + AUTHORIZED = 'authorized', + DENIED = 'denied', + EXPIRED = 'expired', + } +} + diff --git a/frontend/src/api/models/ShareInspectOut.ts b/frontend/src/api/models/ShareInspectOut.ts new file mode 100644 index 000000000..f8759a343 --- /dev/null +++ b/frontend/src/api/models/ShareInspectOut.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ShareRiskItem } from './ShareRiskItem'; +export type ShareInspectOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 分享前检查出的隐私风险项 + */ + risks?: Array; +}; + diff --git a/frontend/src/api/models/ShareRiskItem.ts b/frontend/src/api/models/ShareRiskItem.ts new file mode 100644 index 000000000..d727fa834 --- /dev/null +++ b/frontend/src/api/models/ShareRiskItem.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ShareRiskItem = { + /** + * 存在风险的配置项 + */ + field: string; + /** + * 风险说明 + */ + reason: string; +}; + diff --git a/frontend/src/api/models/ShareTemplateItem.ts b/frontend/src/api/models/ShareTemplateItem.ts new file mode 100644 index 000000000..bd2dbc0e8 --- /dev/null +++ b/frontend/src/api/models/ShareTemplateItem.ts @@ -0,0 +1,43 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ShareTemplateItem = { + /** + * 配置中心项目标识 + */ + projectKey: string; + /** + * 配置中心分类标识 + */ + categoryKey: string; + /** + * 配置中心配置标识 + */ + configKey: string; + /** + * 配置名称 + */ + displayName: string; + /** + * 配置描述 + */ + description?: string; + /** + * 分享者用户名 + */ + ownerUsername?: string; + /** + * 已发布的版本号 + */ + publishedVersionNo?: (number | null); + /** + * 发布时间 + */ + publishedAt?: string; + /** + * 更新时间 + */ + updatedAt?: string; +}; + diff --git a/frontend/src/api/models/ShareTemplateListIn.ts b/frontend/src/api/models/ShareTemplateListIn.ts new file mode 100644 index 000000000..aca824f11 --- /dev/null +++ b/frontend/src/api/models/ShareTemplateListIn.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ShareTemplateListIn = { + /** + * 页码, 从 1 开始 + */ + page?: number; + /** + * 每页条数 + */ + pageSize?: number; + /** + * 搜索关键字 + */ + keyword?: (string | null); +}; + diff --git a/frontend/src/api/models/ShareTemplateListOut.ts b/frontend/src/api/models/ShareTemplateListOut.ts new file mode 100644 index 000000000..76ca385fb --- /dev/null +++ b/frontend/src/api/models/ShareTemplateListOut.ts @@ -0,0 +1,40 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ShareTemplateItem } from './ShareTemplateItem'; +export type ShareTemplateListOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 配置模板列表 + */ + items?: Array; + /** + * 当前页码 + */ + page?: number; + /** + * 每页条数 + */ + pageSize?: number; + /** + * 模板总数 + */ + total?: number; + /** + * 是否还有下一页 + */ + hasNext?: boolean; +}; + diff --git a/frontend/src/api/services/ActionService.ts b/frontend/src/api/services/ActionService.ts index 2ab2cec76..9412034ef 100644 --- a/frontend/src/api/services/ActionService.ts +++ b/frontend/src/api/services/ActionService.ts @@ -21,6 +21,7 @@ import type { PowerIn } from '../models/PowerIn'; import type { ScriptConfigImportIn } from '../models/ScriptConfigImportIn'; import type { ScriptFileIn } from '../models/ScriptFileIn'; import type { ScriptUploadIn } from '../models/ScriptUploadIn'; +import type { ShareAuthStatusOut } from '../models/ShareAuthStatusOut'; import type { TaskCreateIn } from '../models/TaskCreateIn'; import type { TaskCreateOut } from '../models/TaskCreateOut'; import type { WebhookTestIn } from '../models/WebhookTestIn'; @@ -59,7 +60,7 @@ export class ActionService { }); } /** - * 上传脚本配置到网络 + * 分享脚本配置到配置中心 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -342,6 +343,28 @@ export class ActionService { }, }); } + /** + * 发起配置中心浏览器授权 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static startShareAuthApiShareAuthStartPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/start', + }); + } + /** + * 取消等待中的配置中心授权 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static cancelShareAuthApiShareAuthCancelPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/cancel', + }); + } /** * 下载更新 * @param version diff --git a/frontend/src/api/services/GetService.ts b/frontend/src/api/services/GetService.ts index bc2454294..1cebac157 100644 --- a/frontend/src/api/services/GetService.ts +++ b/frontend/src/api/services/GetService.ts @@ -46,7 +46,12 @@ import type { QueueItemGetOut } from '../models/QueueItemGetOut'; import type { ScriptDeleteIn } from '../models/ScriptDeleteIn'; import type { ScriptGetIn } from '../models/ScriptGetIn'; import type { ScriptGetOut } from '../models/ScriptGetOut'; +import type { ScriptShareInspectIn } from '../models/ScriptShareInspectIn'; import type { SettingGetOut } from '../models/SettingGetOut'; +import type { ShareAuthStatusOut } from '../models/ShareAuthStatusOut'; +import type { ShareInspectOut } from '../models/ShareInspectOut'; +import type { ShareTemplateListIn } from '../models/ShareTemplateListIn'; +import type { ShareTemplateListOut } from '../models/ShareTemplateListOut'; import type { TaskRuntimeSnapshot } from '../models/TaskRuntimeSnapshot'; import type { TimeSetGetIn } from '../models/TimeSetGetIn'; import type { TimeSetGetOut } from '../models/TimeSetGetOut'; @@ -177,17 +182,6 @@ export class GetService { url: '/api/info/notice/get', }); } - /** - * 获取配置分享中心的配置信息 - * @returns InfoOut Successful Response - * @throws ApiError - */ - public static getWebConfigApiInfoWebconfigPost(): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/info/webconfig', - }); - } /** * 信息总览 * @returns InfoOut Successful Response @@ -218,6 +212,25 @@ export class GetService { }, }); } + /** + * 分享前检查脚本配置中的隐私风险 + * @param requestBody + * @returns ShareInspectOut Successful Response + * @throws ApiError + */ + public static inspectScriptShareApiScriptsShareInspectPost( + requestBody: ScriptShareInspectIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/scripts/share/inspect', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } /** * 获取 MaaEnd 动态选项 * @param requestBody @@ -704,6 +717,47 @@ export class GetService { url: '/api/setting/virtual-display/status', }); } + /** + * 获取配置中心已发布的通用脚本配置 + * @param requestBody + * @returns ShareTemplateListOut Successful Response + * @throws ApiError + */ + public static listShareTemplatesApiShareTemplatesPost( + requestBody: ShareTemplateListIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/templates', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 获取配置中心授权状态 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static getShareAuthStatusApiShareAuthStatusPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/status', + }); + } + /** + * 轮询配置中心授权结果 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static pollShareAuthApiShareAuthPollPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/poll', + }); + } /** * 获取更新下载初始快照 * 返回当前下载权威状态;WS 只承载后续进度与终态事件。 diff --git a/frontend/src/api/services/Service.ts b/frontend/src/api/services/Service.ts index 74fd16d94..f3edf82e4 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -84,11 +84,16 @@ import type { ScriptFileIn } from '../models/ScriptFileIn'; import type { ScriptGetIn } from '../models/ScriptGetIn'; import type { ScriptGetOut } from '../models/ScriptGetOut'; import type { ScriptReorderIn } from '../models/ScriptReorderIn'; +import type { ScriptShareInspectIn } from '../models/ScriptShareInspectIn'; +import type { ScriptTemplateImportIn } from '../models/ScriptTemplateImportIn'; import type { ScriptUpdateIn } from '../models/ScriptUpdateIn'; import type { ScriptUploadIn } from '../models/ScriptUploadIn'; -import type { ScriptUrlIn } from '../models/ScriptUrlIn'; import type { SettingGetOut } from '../models/SettingGetOut'; import type { SettingUpdateIn } from '../models/SettingUpdateIn'; +import type { ShareAuthStatusOut } from '../models/ShareAuthStatusOut'; +import type { ShareInspectOut } from '../models/ShareInspectOut'; +import type { ShareTemplateListIn } from '../models/ShareTemplateListIn'; +import type { ShareTemplateListOut } from '../models/ShareTemplateListOut'; import type { SklandQrCheckIn } from '../models/SklandQrCheckIn'; import type { SklandQrCheckOut } from '../models/SklandQrCheckOut'; import type { SklandQrCreateOut } from '../models/SklandQrCreateOut'; @@ -319,17 +324,6 @@ export class Service { url: '/api/info/notice/confirm', }); } - /** - * 获取配置分享中心的配置信息 - * @returns InfoOut Successful Response - * @throws ApiError - */ - public static getWebConfigApiInfoWebconfigPost(): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/info/webconfig', - }); - } /** * 信息总览 * @returns InfoOut Successful Response @@ -475,13 +469,13 @@ export class Service { }); } /** - * 从网络加载脚本配置 + * 从配置中心导入脚本配置 * @param requestBody * @returns OutBase Successful Response * @throws ApiError */ public static importScriptFromWebApiScriptsImportWebPost( - requestBody: ScriptUrlIn, + requestBody: ScriptTemplateImportIn, ): CancelablePromise { return __request(OpenAPI, { method: 'POST', @@ -494,7 +488,26 @@ export class Service { }); } /** - * 上传脚本配置到网络 + * 分享前检查脚本配置中的隐私风险 + * @param requestBody + * @returns ShareInspectOut Successful Response + * @throws ApiError + */ + public static inspectScriptShareApiScriptsShareInspectPost( + requestBody: ScriptShareInspectIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/scripts/share/inspect', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 分享脚本配置到配置中心 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -2771,6 +2784,69 @@ export class Service { url: '/api/setting/virtual-display/status', }); } + /** + * 获取配置中心已发布的通用脚本配置 + * @param requestBody + * @returns ShareTemplateListOut Successful Response + * @throws ApiError + */ + public static listShareTemplatesApiShareTemplatesPost( + requestBody: ShareTemplateListIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/templates', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 获取配置中心授权状态 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static getShareAuthStatusApiShareAuthStatusPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/status', + }); + } + /** + * 发起配置中心浏览器授权 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static startShareAuthApiShareAuthStartPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/start', + }); + } + /** + * 轮询配置中心授权结果 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static pollShareAuthApiShareAuthPollPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/poll', + }); + } + /** + * 取消等待中的配置中心授权 + * @returns ShareAuthStatusOut Successful Response + * @throws ApiError + */ + public static cancelShareAuthApiShareAuthCancelPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/share/auth/cancel', + }); + } /** * 获取更新下载初始快照 * 返回当前下载权威状态;WS 只承载后续进度与终态事件。 diff --git a/frontend/src/api/services/UpdateService.ts b/frontend/src/api/services/UpdateService.ts index 493bfb807..f1ddebebf 100644 --- a/frontend/src/api/services/UpdateService.ts +++ b/frontend/src/api/services/UpdateService.ts @@ -13,8 +13,8 @@ import type { QueueReorderIn } from '../models/QueueReorderIn'; import type { QueueUpdateIn } from '../models/QueueUpdateIn'; import type { ScriptFileIn } from '../models/ScriptFileIn'; import type { ScriptReorderIn } from '../models/ScriptReorderIn'; +import type { ScriptTemplateImportIn } from '../models/ScriptTemplateImportIn'; import type { ScriptUpdateIn } from '../models/ScriptUpdateIn'; -import type { ScriptUrlIn } from '../models/ScriptUrlIn'; import type { SettingUpdateIn } from '../models/SettingUpdateIn'; import type { TimeSetReorderIn } from '../models/TimeSetReorderIn'; import type { TimeSetUpdateIn } from '../models/TimeSetUpdateIn'; @@ -86,13 +86,13 @@ export class UpdateService { }); } /** - * 从网络加载脚本配置 + * 从配置中心导入脚本配置 * @param requestBody * @returns OutBase Successful Response * @throws ApiError */ public static importScriptFromWebApiScriptsImportWebPost( - requestBody: ScriptUrlIn, + requestBody: ScriptTemplateImportIn, ): CancelablePromise { return __request(OpenAPI, { method: 'POST', diff --git a/frontend/src/composables/useShareApi.ts b/frontend/src/composables/useShareApi.ts new file mode 100644 index 000000000..64a48963f --- /dev/null +++ b/frontend/src/composables/useShareApi.ts @@ -0,0 +1,139 @@ +import { ref } from 'vue' +import { Service, type ShareAuthStatusOut, type ShareRiskItem } from '@/api' + +export type { ShareRiskItem } + +export type ShareAuthStatus = ShareAuthStatusOut['authStatus'] + +export interface ShareAuthState { + status: ShareAuthStatus + username: string + displayName: string + userCode: string + verificationUri: string + interval: number + message: string +} + +export const IDLE_SHARE_AUTH: ShareAuthState = { + status: 'idle' as ShareAuthStatus, + username: '', + displayName: '', + userCode: '', + verificationUri: '', + interval: 5, + message: '', +} + +// 只有被拒绝或已过期时后端才会带回可展示的原因,其余状态的 message 是通用的成功文案 +const RESULT_STATUSES: ShareAuthStatus[] = ['denied', 'expired'] as ShareAuthStatus[] + +const toAuthState = (response: ShareAuthStatusOut): ShareAuthState => ({ + status: response.authStatus, + username: response.username ?? '', + displayName: response.displayName ?? '', + userCode: response.userCode ?? '', + verificationUri: response.verificationUri ?? '', + interval: response.interval ?? 5, + message: RESULT_STATUSES.includes(response.authStatus) ? (response.message ?? '') : '', +}) + +export function useShareApi() { + const loading = ref(false) + const error = ref(null) + + const runAuthCall = async ( + call: () => Promise + ): Promise => { + error.value = null + try { + const response = await call() + if (response.code !== 200) { + error.value = response.message || '配置中心授权失败' + return null + } + return toAuthState(response) + } catch (err) { + error.value = err instanceof Error ? err.message : '配置中心授权失败' + return null + } + } + + const getShareAuthStatus = () => + runAuthCall(() => Service.getShareAuthStatusApiShareAuthStatusPost()) + + const startShareAuth = async () => { + loading.value = true + try { + return await runAuthCall(() => Service.startShareAuthApiShareAuthStartPost()) + } finally { + loading.value = false + } + } + + const pollShareAuth = () => runAuthCall(() => Service.pollShareAuthApiShareAuthPollPost()) + + const cancelShareAuth = () => + runAuthCall(() => Service.cancelShareAuthApiShareAuthCancelPost()) + + // 分享前检查:返回自动脱敏后仍然可疑的配置项,由用户确认 + const inspectShare = async ( + scriptId: string, + configName: string + ): Promise => { + error.value = null + try { + const response = await Service.inspectScriptShareApiScriptsShareInspectPost({ + scriptId, + config_name: configName, + }) + if (response.code !== 200) { + error.value = response.message || '分享前检查失败' + return null + } + return response.risks ?? [] + } catch (err) { + error.value = err instanceof Error ? err.message : '分享前检查失败' + return null + } + } + + const uploadShare = async (payload: { + scriptId: string + configName: string + description: string + acknowledged: boolean + }): Promise => { + loading.value = true + error.value = null + try { + const response = await Service.uploadScriptToWebApiScriptsUploadWebPost({ + scriptId: payload.scriptId, + config_name: payload.configName, + description: payload.description, + acknowledged: payload.acknowledged, + }) + if (response.code !== 200) { + error.value = response.message || '上传失败' + return false + } + return true + } catch (err) { + error.value = err instanceof Error ? err.message : '上传失败' + return false + } finally { + loading.value = false + } + } + + return { + loading, + error, + getShareAuthStatus, + startShareAuth, + pollShareAuth, + cancelShareAuth, + inspectShare, + uploadShare, + } +} diff --git a/frontend/src/composables/useTemplateApi.ts b/frontend/src/composables/useTemplateApi.ts index c7ad32c18..debddc80a 100644 --- a/frontend/src/composables/useTemplateApi.ts +++ b/frontend/src/composables/useTemplateApi.ts @@ -1,85 +1,97 @@ import { ref } from 'vue' import { message } from 'ant-design-vue' -import { Service } from '@/api' +import { Service, type ShareTemplateItem } from '@/api' -export interface WebConfigTemplate { - configName: string - description: string - author: string - createTime: string - downloadUrl: string +export type { ShareTemplateItem } + +export interface TemplateQuery { + page: number + pageSize: number + keyword: string } -export interface WebConfigResponse { - code: number - status: string - message: string - data: { - WebConfig: WebConfigTemplate[] - } +export interface TemplatePage { + items: ShareTemplateItem[] + page: number + pageSize: number + total: number + hasNext: boolean } -const isWebConfigResponseData = (value: unknown): value is WebConfigResponse['data'] => - typeof value === 'object' && - value !== null && - Array.isArray((value as Partial).WebConfig) +export const TEMPLATE_PAGE_SIZE = 10 + +const emptyPage = (query: TemplateQuery): TemplatePage => ({ + items: [], + page: query.page, + pageSize: query.pageSize, + total: 0, + hasNext: false, +}) export function useTemplateApi() { const loading = ref(false) const error = ref(null) - // 获取Web配置模板列表 - const getWebConfigTemplates = async (): Promise => { + // 拉取配置中心已审核发布的通用脚本配置;搜索与翻页都在服务端完成 + const getShareTemplates = async (query: TemplateQuery): Promise => { loading.value = true error.value = null try { - const response = await Service.getWebConfigApiInfoWebconfigPost() + const response = await Service.listShareTemplatesApiShareTemplatesPost({ + page: query.page, + pageSize: query.pageSize, + keyword: query.keyword || null, + }) if (response.code !== 200) { const errorMsg = response.message || '获取模板列表失败' - message.error(errorMsg) - throw new Error(errorMsg) + error.value = errorMsg + return emptyPage(query) } - // 直接返回API响应中的WebConfig数组 - return isWebConfigResponseData(response.data) ? response.data.WebConfig : [] - } catch (err) { - const errorMsg = err instanceof Error ? err.message : '获取模板列表失败' - error.value = errorMsg - if (err instanceof Error && !err.message.includes('HTTP error')) { - message.error(errorMsg) + return { + items: response.items ?? [], + page: response.page ?? query.page, + pageSize: response.pageSize ?? query.pageSize, + total: response.total ?? 0, + hasNext: response.hasNext ?? false, } - return [] + } catch (err) { + error.value = err instanceof Error ? err.message : '获取模板列表失败' + return emptyPage(query) } finally { loading.value = false } } - // 从Web导入脚本配置 - const importScriptFromWeb = async (scriptId: string, url: string): Promise => { + // 按配置中心的结构化标识导入配置,渲染进程不再提交下载地址 + const importScriptFromTemplate = async ( + scriptId: string, + template: ShareTemplateItem + ): Promise => { loading.value = true error.value = null try { const response = await Service.importScriptFromWebApiScriptsImportWebPost({ scriptId, - url, + configKey: template.configKey, + versionNo: template.publishedVersionNo ?? null, }) if (response.code !== 200) { const errorMsg = response.message || '导入配置失败' + error.value = errorMsg message.error(errorMsg) - throw new Error(errorMsg) + return false } return true } catch (err) { const errorMsg = err instanceof Error ? err.message : '导入配置失败' error.value = errorMsg - if (err instanceof Error && !err.message.includes('HTTP error')) { - message.error(errorMsg) - } + message.error(errorMsg) return false } finally { loading.value = false @@ -89,7 +101,7 @@ export function useTemplateApi() { return { loading, error, - getWebConfigTemplates, - importScriptFromWeb, + getShareTemplates, + importScriptFromTemplate, } } diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 72ab05d4a..30d5fd8aa 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -697,6 +697,26 @@ export default { whenSavingMasEncrypts: 'When saving, MAS encrypts the account password. Without SRA configured, or when the SRA module is unused, the password is not used for account switching.', aboutSharing: 'About sharing', + share: { + loginRequired: 'Sign in to the config center first', + loginRequiredDesc: + 'The author is taken from your signed-in account, so there is nothing to fill in by hand.', + startLogin: 'Sign in to config center', + startFailed: 'Could not start the config center sign-in', + pendingDesc: + 'The authorization page is open in your browser. Check the code below, then choose Approve:', + reopenBrowser: 'Reopen the page', + cancelAuth: 'Cancel sign-in', + authorized: 'Signed in to the config center: {name}', + signedInAs: 'Sharing as {name}', + signedInDesc: + 'The upload enters the config center review queue and becomes visible once approved.', + switchAccount: 'Use another account', + riskTitle: 'These fields may still contain something you should not publish', + riskConfirm: 'I confirm the items above are safe to share publicly', + privacyNotice: + 'Known paths such as the script root and script path are replaced with placeholders before upload, and user data is never uploaded. The upload enters the review queue and becomes downloadable once approved.', + }, singleFile: 'Single file', match: 'Match', multiLineAggregationGuide: 'Multi-line aggregation guide', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 4b2c9ab8f..22d8c09a7 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -684,6 +684,23 @@ export default { whenSavingMasEncrypts: '保存时 MAS 会自动加密账号密码。未配置 SRA 或未使用 SRA 模块时,账号密码不会用于切号。', aboutSharing: '分享说明', + share: { + loginRequired: '需要先登录配置中心', + loginRequiredDesc: '分享者身份由登录账号决定,登录后即可提交,不需要手填作者。', + startLogin: '登录配置中心', + startFailed: '无法发起配置中心登录', + pendingDesc: '已在浏览器中打开授权页面,请核对下面的短码后点击「授权」:', + reopenBrowser: '重新打开授权页', + cancelAuth: '取消登录', + authorized: '已登录配置中心:{name}', + signedInAs: '将以 {name} 的身份分享', + signedInDesc: '提交后进入配置中心的待审核流程,通过审核后其他用户才能看到。', + switchAccount: '换个账号', + riskTitle: '这些配置项可能仍包含不该公开的内容', + riskConfirm: '我已确认上述内容可以公开分享', + privacyNotice: + '脚本根目录、脚本路径等已知路径会在上传前自动替换为占位符,用户数据不会上传。提交后进入配置中心待审核流程,通过审核后其他用户才能下载使用。', + }, singleFile: '单文件', match: '命中', multiLineAggregationGuide: '多行聚合指南', diff --git a/frontend/src/views/EditView/Script/GeneralScriptEdit.vue b/frontend/src/views/EditView/Script/GeneralScriptEdit.vue index 7270f6824..1a8f5f51b 100644 --- a/frontend/src/views/EditView/Script/GeneralScriptEdit.vue +++ b/frontend/src/views/EditView/Script/GeneralScriptEdit.vue @@ -886,17 +886,6 @@ /> - - - - + + + + {{ t('edit.share.signedInDesc') }} + {{ + t('edit.share.switchAccount') + }} + + + + + + + {{ t('edit.share.pendingDesc') }} + {{ shareAuth.userCode }} + + {{ + t('edit.share.reopenBrowser') + }} + {{ + t('edit.share.cancelAuth') + }} + + + + {{ shareAuth.message || t('edit.share.loginRequiredDesc') }} + {{ t('edit.share.startLogin') }} + + + + + + + + + {{ risk.field }} — {{ risk.reason }} + + + {{ + t('edit.share.riskConfirm') + }} + + + - - 所有 敏感信息 均会在上传前自动移除,上传内容仅包含脚本配置的非敏感信息。上传且通过审核后,其他用户可以下载并使用您的脚本配置。请确保配置信息准确且描述清晰。 - + {{ t('edit.share.privacyNotice') }} @@ -923,9 +977,9 @@
{{ t('edit.share.signedInDesc') }}
{{ t('edit.share.pendingDesc') }}
{{ shareAuth.userCode }}
{{ shareAuth.message || t('edit.share.loginRequiredDesc') }}
{{ risk.field }}
- 所有 敏感信息 均会在上传前自动移除,上传内容仅包含脚本配置的非敏感信息。上传且通过审核后,其他用户可以下载并使用您的脚本配置。请确保配置信息准确且描述清晰。 -
{{ t('edit.share.privacyNotice') }}