From c53baace8da84cf3ec897f2b4447a17a34ad827e Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 8 Sep 2026 17:07:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(share):=20=E9=80=9A=E7=94=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=A8=A1=E6=9D=BF=E4=B8=8E=E5=88=86=E4=BA=AB=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=96=B0=E7=89=88=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模板列表、搜索、分页与下载改用配置中心的 /api/v1/configs 与结构化下载接口, 渲染进程不再提交任意下载 URL;下载有体积上限与格式校验,导入失败会删掉刚建出来 的空脚本。 分享改为设备授权:桌面端申请人读短码,用系统浏览器打开配置中心授权页,用户确认后 换取一枚只能上传配置的短期令牌,令牌只留在内存里。分享表单去掉作者,身份由登录 账号决定,上传后进入配置中心已有的待审核流程。 分享前的隐私清理扩展到全部字段,并新增一次扫描:疑似账号、密码、令牌、本机用户名 或本机绝对路径的配置项会列给用户,确认后才允许上传。 Closes #624 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- app/api/__init__.py | 2 + app/api/info.py | 18 - app/api/scripts.py | 44 +- app/api/share.py | 158 ++++++ app/core/config.py | 240 +++++---- app/models/config.py | 11 - app/models/schema.py | 73 ++- app/services/__init__.py | 3 +- app/services/config_center.py | 477 ++++++++++++++++++ frontend/src/api/index.ts | 9 +- ...ScriptUrlIn.ts => ScriptShareInspectIn.ts} | 6 +- .../src/api/models/ScriptTemplateImportIn.ts | 19 + frontend/src/api/models/ScriptUploadIn.ts | 8 +- frontend/src/api/models/ShareAuthStatusOut.ts | 59 +++ frontend/src/api/models/ShareInspectOut.ts | 24 + frontend/src/api/models/ShareRiskItem.ts | 15 + frontend/src/api/models/ShareTemplateItem.ts | 43 ++ .../src/api/models/ShareTemplateListIn.ts | 19 + .../src/api/models/ShareTemplateListOut.ts | 40 ++ frontend/src/api/services/ActionService.ts | 25 +- frontend/src/api/services/GetService.ts | 76 ++- frontend/src/api/services/Service.ts | 106 +++- frontend/src/api/services/UpdateService.ts | 6 +- frontend/src/composables/useShareApi.ts | 139 +++++ frontend/src/composables/useTemplateApi.ts | 92 ++-- frontend/src/i18n/locales/en-US.ts | 20 + frontend/src/i18n/locales/zh-CN.ts | 17 + .../EditView/Script/GeneralScriptEdit.vue | 283 +++++++++-- frontend/src/views/Scripts.vue | 114 +++-- .../scripts/components/ScriptCreateDialog.vue | 177 ++++--- .../scripts/components/scriptCreateFlow.ts | 11 +- main.py | 2 + res/version.json | 1 + tests/core/test_share_privacy.py | 107 ++++ 35 files changed, 2062 insertions(+), 385 deletions(-) create mode 100644 app/api/share.py create mode 100644 app/services/config_center.py rename frontend/src/api/models/{ScriptUrlIn.ts => ScriptShareInspectIn.ts} (71%) create mode 100644 frontend/src/api/models/ScriptTemplateImportIn.ts create mode 100644 frontend/src/api/models/ShareAuthStatusOut.ts create mode 100644 frontend/src/api/models/ShareInspectOut.ts create mode 100644 frontend/src/api/models/ShareRiskItem.ts create mode 100644 frontend/src/api/models/ShareTemplateItem.ts create mode 100644 frontend/src/api/models/ShareTemplateListIn.ts create mode 100644 frontend/src/api/models/ShareTemplateListOut.ts create mode 100644 frontend/src/composables/useShareApi.ts create mode 100644 tests/core/test_share_privacy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f5384297b..a2e137612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,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) @@ -98,7 +99,7 @@ ### 修复 -- 社区签到结果统一附加到任务报告,修复重复推送并按通知渠道展示奖励与失败原因 +- 社区签到结果统一附加到任务报告,修复重复推送并按通知渠道展示奖励与失败原因 by [@Lance0174](https://github.com/Lance0174) - Runtime 接入 修复首次初始化时 uv 下载完成瞬间「安装 Python」被显示为完成、随后进度又倒退的问题 by [@qiyinxi](https://github.com/qiyinxi) - 修复启动界面「查看日志」打开的窗口一片空白、后端启动失败时没有查看日志入口、出错说明文字贴在窗口顶部,以及日志文件打不开时没有任何提示(历史记录页还会误报「日志文件已打开」)的问题 by [@qiyinxi](https://github.com/qiyinxi) - 修复深色模式下初始化界面连同标题栏整体变暗的问题 by [@qiyinxi](https://github.com/qiyinxi) diff --git a/app/api/__init__.py b/app/api/__init__.py index 3d8db26a8..995c07bbe 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 .tools import router as tools_router from .update import router as update_router @@ -50,6 +51,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 a2c1974d3..335560d1d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -952,10 +952,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: @@ -965,41 +980,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) @@ -1014,35 +1018,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: """移除配置中可能存在的隐私信息""" @@ -1066,8 +1068,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]: @@ -3591,49 +3668,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 44c1f60e6..37ccbdb72 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -4364,17 +4364,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 3636b2d46..36b362a93 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -3516,16 +3516,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 7670b2567..5e2e4000c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -303,11 +303,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 { SklandLoginIn } from './models/SklandLoginIn'; export type { SrcConfig } from './models/SrcConfig'; export type { SrcConfig_Emulator } from './models/SrcConfig_Emulator'; 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 591c7dcfc..63c4327d1 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -82,11 +82,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 { SklandLoginIn } from '../models/SklandLoginIn'; import type { TaskCreateIn } from '../models/TaskCreateIn'; import type { TaskCreateOut } from '../models/TaskCreateOut'; @@ -314,17 +319,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 @@ -470,13 +464,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', @@ -489,7 +483,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 @@ -2766,6 +2779,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 27e7fff97..76849e051 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 e9dbe25f9..ba7efa3d9 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 @@ /> - - - - + + + + + @@ -923,9 +977,9 @@