commit 76147bae942419f391f628993b7f2b9478fcdb67 Author: Falck Date: Mon Apr 6 09:57:10 2026 +0800 ⚡ 初始提交 - FutureOSS v1.0 插件化运行时框架 一切皆为插件的开发者工具运行时框架 🧩 核心特性: - 插件热插拔 (importlib 动态加载) - 依赖自动解析 (拓扑排序 + 循环检测) - 企业级稳定 (熔断/降级/重试/隔离) - 事件驱动 (发布/订阅事件总线) - 完整配置 (YAML 配置 + 热重载) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fdd7cf4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +*.egg-info/ +.pytest_cache/ +.git/ +.vscode/ +.cache/ +logs/ +config.yaml +data/pkg/ +data/DCIM/ +data/html-render/*.json +data/web-toolkit/*.json +data/plugin-storage/*.json +*.so +*.dll +*.dylib diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a2de83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +.qwen/ +QWEN.md +.vscode/ +bin/ +logs/ +*.log +config.yaml +package.json +.cache/ +data/ + +# Web 运行时文件 +start-web.sh +website/router.php +admin/ + +# 数据库配置(含密码) +website/community/config.php +website/community/.env +admin/includes/config.php + +# 日志 +logs/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e397be2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM python:3.12-slim AS builder + +WORKDIR /app + +# 构建依赖 +COPY pyproject.toml ./ +COPY README.md ./ + +# 安装 Python 依赖 +RUN pip install --no-cache-dir --prefix=/install . 2>/dev/null || \ + pip install --no-cache-dir --prefix=/install --break-system-packages . 2>/dev/null || true + +# 兜底核心依赖 +RUN pip install --no-cache-dir --prefix=/install click pyyaml websockets 2>/dev/null || true + +# ───────────────────────────────────────────────────────── +FROM python:3.12-slim + +LABEL maintainer="Falck " +LABEL description="FutureOSS — 一切皆为插件的开发者工具运行时框架" + +WORKDIR /app + +# 复制依赖 +COPY --from=builder /install /usr/local + +# 复制项目文件 +COPY oss/ ./oss/ +COPY store/ ./store/ +COPY data/ ./data/ +COPY start.sh ./start.sh +COPY pyproject.toml ./pyproject.toml +COPY README.md ./README.md + +# 创建必要目录 +RUN mkdir -p /app/data/html-render /app/data/web-toolkit /app/data/plugin-storage /app/data/DCIM /app/data/pkg /app/logs + +# 暴露端口 +EXPOSE 8080 8081 8082 + +# 健康检查 +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# 启动 +CMD ["python", "-m", "oss.cli", "serve"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..571b265 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Falck + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd6423b --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +
+ FutureOSS Banner +
+ +--- + +
+ +## 📂 项目结构 + +
+ +``` +FutureOSS/ +├── 🚀 pyproject.toml # Python 项目配置 +├── 📋 oss/ # 核心框架包 +│ ├── cli.py # CLI 命令入口 +│ ├── config/ # 配置系统 +│ ├── logger/ # 日志系统 +│ ├── plugin/ # 插件框架 (接口/加载器/管理器) +│ └── server/ # HTTP 服务器 +├── 🧩 store/ # 本地插件仓库 +│ └── @{作者/插件名}/ # 插件目录 (含 manifest.json) +├── 📦 data/ # 运行时数据目录 +│ ├── html-render/ # 网站文件 (通过 config.json 配置) +│ ├── web-toolkit/ # Web 工具配置 +│ ├── plugin-storage/ # 插件存储配置 +│ └── DCIM/ # 共享存储 +├── 🌐 website/ # 官网 + 社区 (PHP) +└── 📖 static/ # 静态资源 (SVG 背景等) +``` + +
+ +## 📖 文档 + +
+ +所有文档都在本地 `dock/` 目录中: + +| 📘 页面 | 📝 内容 | +|:---:|:---| +| [🎯 项目介绍](./dock/00-项目介绍/) | 什么是 FutureOSS、架构设计、核心概念 | +| [🚀 快速开始](./dock/01-快速开始/) | 安装、配置、第一次运行 | +| [🔌 插件开发](./dock/02-插件开发/) | 编写你的第一个插件、事件系统 | +| [📄 插件文档](./dock/03-插件文档/) | http-api、ws-api、file 插件详解 | +| [📦 包管理](./dock/04-包管理/) | 安装/卸载/搜索/发布插件 | +| [⚙️ 配置参考](./dock/05-配置参考/) | 配置参数详解 | +| [🚢 部署运维](./dock/06-部署运维/) | 本地运行、Docker、生产环境 | +| [🌟 社区与贡献](./dock/07-社区与贡献/) | 贡献指南、行为准则 | + +
+ +## 🔗 远程仓库 + +
+ +
+ +| 📦 代码仓库 | 📚 包仓库 | +|:---:|:---:| +| [Gitee](https://gitee.com/starlight-apk/feature-oss) | [Gitee Pkg](https://gitee.com/starlight-apk/future-oss-pkg) | + +
+ +--- + +
+ +## 📜 许可证 + +**[Apache License 2.0](LICENSE)** + +Copyright 2026 Falck + +本项目采用 Apache 2.0 许可证 — 你可以自由使用、修改和分发,需保留版权和许可证声明。 + +--- + +### 📝 作者声明 + +> 本项目采用 Apache 2.0 开源许可证,此为独立于许可证的补充说明: + +- 🚫 **禁止未经作者(Falck)明确书面许可的二次转发、搬运、转载** +- 🚫 **禁止冒充原作者或声称与官方项目存在关联** +- 🚫 **禁止移除、修改或遮盖版权声明、许可证和 NOTICE 文件** +- ✅ **允许个人学习、研究、商业使用(需保留版权和许可证信息)** + +> 此声明不改变 Apache 2.0 许可证的法律效力,仅表达作者的合理期望。 +> 如需特殊授权,请联系作者。 + +
+ +
+ +--- + +

+ ⚡ FutureOSS — 一切皆为插件 +

+ +

+ Made with ❤️ by Falck +

+ +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99825a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + futureoss: + build: + context: . + dockerfile: Dockerfile + container_name: futureoss + restart: unless-stopped + ports: + - "8080:8080" # HTTP API + 网站 + - "8081:8081" # WebSocket + - "8082:8082" # HTTP TCP + volumes: + # 插件热更新(无需重建镜像) + - ./store/@{FutureOSS}:/app/store/@{FutureOSS}:ro + - ./store/@{Falck}:/app/store/@{Falck}:ro + # 数据持久化 + - futureoss-data:/app/data + # 配置可覆盖 + - ./config.yaml:/app/config.yaml:ro + environment: + - TZ=Asia/Shanghai + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 128M + +volumes: + futureoss-data: + driver: local diff --git a/oss/__init__.py b/oss/__init__.py new file mode 100644 index 0000000..673db08 --- /dev/null +++ b/oss/__init__.py @@ -0,0 +1,2 @@ +"""Future OSS""" +__version__ = "1.0.0" diff --git a/oss/__pycache__/__init__.cpython-313.pyc b/oss/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d862c0d Binary files /dev/null and b/oss/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/__pycache__/cli.cpython-313.pyc b/oss/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..2a117f1 Binary files /dev/null and b/oss/__pycache__/cli.cpython-313.pyc differ diff --git a/oss/__pycache__/oss_parser.cpython-313.pyc b/oss/__pycache__/oss_parser.cpython-313.pyc new file mode 100644 index 0000000..720bdfe Binary files /dev/null and b/oss/__pycache__/oss_parser.cpython-313.pyc differ diff --git a/oss/cli.py b/oss/cli.py new file mode 100644 index 0000000..68ee536 --- /dev/null +++ b/oss/cli.py @@ -0,0 +1,56 @@ +"""CLI 入口""" +import click +import signal + +from oss import __version__ +from oss.logger.logger import Logger +from oss.plugin.manager import PluginManager + + +@click.group() +def cli(): + """Future OSS - 一切皆为插件""" + pass + + +@cli.command() +def serve(): + """启动 Future OSS""" + log = Logger() + log.info(f"Future OSS {__version__} 启动") + + plugin_mgr = PluginManager() + plugin_mgr.load() + plugin_mgr.start() + + log.info("就绪") + + def shutdown(sig, frame): + log.info("停止中...") + plugin_mgr.stop() + log.info("已停止") + raise SystemExit(0) + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + try: + signal.pause() + except AttributeError: + import time + while True: + time.sleep(1) + + +@cli.command() +def version(): + """显示版本""" + click.echo(f"Future OSS {__version__}") + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/oss/config/__pycache__/__init__.cpython-313.pyc b/oss/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..90f6dc8 Binary files /dev/null and b/oss/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/config/__pycache__/config.cpython-313.pyc b/oss/config/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..aa7175f Binary files /dev/null and b/oss/config/__pycache__/config.cpython-313.pyc differ diff --git a/oss/logger/__pycache__/__init__.cpython-313.pyc b/oss/logger/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5c977cc Binary files /dev/null and b/oss/logger/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/logger/__pycache__/logger.cpython-313.pyc b/oss/logger/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..2dc99ad Binary files /dev/null and b/oss/logger/__pycache__/logger.cpython-313.pyc differ diff --git a/oss/logger/logger.py b/oss/logger/logger.py new file mode 100644 index 0000000..47f44ad --- /dev/null +++ b/oss/logger/logger.py @@ -0,0 +1,15 @@ +"""日志系统 - 空壳,由日志插件提供实际功能""" +class Logger: + """日志记录器(空壳)""" + + def info(self, msg: str, **kwargs): + print(f"[INFO] {msg}") + + def warn(self, msg: str, **kwargs): + print(f"[WARN] {msg}") + + def error(self, msg: str, **kwargs): + print(f"[ERROR] {msg}") + + def debug(self, msg: str, **kwargs): + print(f"[DEBUG] {msg}") diff --git a/oss/plugin/__pycache__/__init__.cpython-313.pyc b/oss/plugin/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..272be63 Binary files /dev/null and b/oss/plugin/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/capabilities.cpython-313.pyc b/oss/plugin/__pycache__/capabilities.cpython-313.pyc new file mode 100644 index 0000000..1754cc6 Binary files /dev/null and b/oss/plugin/__pycache__/capabilities.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/event_bus.cpython-313.pyc b/oss/plugin/__pycache__/event_bus.cpython-313.pyc new file mode 100644 index 0000000..902a2b6 Binary files /dev/null and b/oss/plugin/__pycache__/event_bus.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/loader.cpython-313.pyc b/oss/plugin/__pycache__/loader.cpython-313.pyc new file mode 100644 index 0000000..2d0e4f4 Binary files /dev/null and b/oss/plugin/__pycache__/loader.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/manager.cpython-313.pyc b/oss/plugin/__pycache__/manager.cpython-313.pyc new file mode 100644 index 0000000..737f18b Binary files /dev/null and b/oss/plugin/__pycache__/manager.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/types.cpython-313.pyc b/oss/plugin/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..905b7c3 Binary files /dev/null and b/oss/plugin/__pycache__/types.cpython-313.pyc differ diff --git a/oss/plugin/capabilities.py b/oss/plugin/capabilities.py new file mode 100644 index 0000000..2cc4068 --- /dev/null +++ b/oss/plugin/capabilities.py @@ -0,0 +1,73 @@ +"""能力扫描器 - 自动扫描插件支持的能力""" +import ast +from pathlib import Path +from typing import Any + + +def scan_capabilities(plugin_dir: Path) -> Any: + """扫描插件目录,自动发现支持的能力""" + capabilities: set[str] = set() + main_file = plugin_dir / "main.py" + + if not main_file.exists(): + return capabilities + + with open(main_file, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + + # 扫描规则: + # 1. 检查是否导出了特定的类或函数 + # 2. 检查是否有特定的装饰器或标记 + # 3. 检查 import 语句(表示依赖了某个能力) + + for node in ast.walk(tree): + # 检查类定义 + if isinstance(node, ast.ClassDef): + class_name = node.name + # 如果类名包含特定后缀,认为是能力提供者 + if class_name.endswith("Provider"): + cap_name = class_name.replace("Provider", "").lower() + capabilities.add(cap_name) + elif class_name.endswith("Mixin"): + cap_name = class_name.replace("Mixin", "").lower() + capabilities.add(cap_name) + elif class_name.endswith("Support"): + cap_name = class_name.replace("Support", "").lower() + capabilities.add(cap_name) + + # 检查函数定义 + elif isinstance(node, ast.FunctionDef): + func_name = node.name + # 检查是否有能力相关的装饰器 + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name): + if decorator.id.startswith("provides_"): + cap_name = decorator.id.replace("provides_", "") + capabilities.add(cap_name) + elif isinstance(decorator, ast.Attribute): + if decorator.attr.startswith("provides_"): + cap_name = decorator.attr.replace("provides_", "") + capabilities.add(cap_name) + + # 检查 import 语句(表示使用了某个能力) + elif isinstance(node, ast.Import): + for alias in node.names: + if "circuit" in alias.name.lower() or "breaker" in alias.name.lower(): + capabilities.add("circuit_breaker") + elif "retry" in alias.name.lower(): + capabilities.add("retry") + elif "cache" in alias.name.lower(): + capabilities.add("cache") + + elif isinstance(node, ast.ImportFrom): + if node.module: + if "circuit" in node.module.lower() or "breaker" in node.module.lower(): + capabilities.add("circuit_breaker") + elif "retry" in node.module.lower(): + capabilities.add("retry") + elif "cache" in node.module.lower(): + capabilities.add("cache") + + return capabilities diff --git a/oss/plugin/loader.py b/oss/plugin/loader.py new file mode 100644 index 0000000..a1f884c --- /dev/null +++ b/oss/plugin/loader.py @@ -0,0 +1,125 @@ +"""插件加载器 - 加载基础插件(带沙箱隔离)""" +import sys +import importlib.util +from pathlib import Path +from typing import Any, Optional + +from oss.plugin.types import Plugin + + +class Sandbox: + """沙箱隔离""" + + def __init__(self): + self._restricted_builtins = { + "__builtins__": { + "True": True, + "False": False, + "None": None, + "dict": dict, + "list": list, + "str": str, + "int": int, + "float": float, + "bool": bool, + "tuple": tuple, + "set": set, + "len": len, + "range": range, + "enumerate": enumerate, + "zip": zip, + "map": map, + "filter": filter, + "sorted": sorted, + "reversed": reversed, + "min": min, + "max": max, + "sum": sum, + "abs": abs, + "round": round, + "isinstance": isinstance, + "issubclass": issubclass, + "type": type, + "id": id, + "hash": hash, + "repr": repr, + "str": str, + "int": int, + "float": float, + "list": list, + "dict": dict, + "set": set, + "tuple": tuple, + "print": print, + } + } + + def get_safe_globals(self) -> dict: + """获取安全的 globals""" + return dict(self._restricted_builtins) + + +class PluginLoader: + """插件加载器(带沙箱隔离)""" + + def __init__(self, enable_sandbox: bool = True): + self.loaded: dict[str, Any] = {} + self.sandbox = Sandbox() if enable_sandbox else None + + def load_core_plugin(self, plugin_name: str, store_dir: str = "store") -> Optional[dict[str, Any]]: + """加载核心插件(不受沙箱限制)""" + plugin_dir = Path(store_dir) / "@{FutureOSS}" / plugin_name + return self._load_plugin(plugin_name, plugin_dir, use_sandbox=False, allow_relative=True) + + def load_sandbox_plugin(self, plugin_dir: Path) -> Optional[dict[str, Any]]: + """加载沙箱插件""" + plugin_name = plugin_dir.name + result = self._load_plugin(plugin_name, plugin_dir, use_sandbox=True, allow_relative=True) + return result + + def _load_plugin(self, plugin_name: str, plugin_dir: Path, use_sandbox: bool = True, allow_relative: bool = False) -> Optional[dict[str, Any]]: + """加载插件""" + if not (plugin_dir / "main.py").exists(): + return None + + # 清理插件名(去掉 } 等) + clean_name = plugin_name.rstrip("}") + module_name = f"plugin.{clean_name}" + spec = importlib.util.spec_from_file_location(module_name, str(plugin_dir / "main.py")) + module = importlib.util.module_from_spec(spec) + module.__package__ = module_name + module.__path__ = [str(plugin_dir)] # 启用相对导入子模块 + sys.modules[spec.name] = module + + # 沙箱模式:限制内置函数 + if use_sandbox and self.sandbox: + safe_globals = self.sandbox.get_safe_globals() + # 允许导入框架模块 + safe_globals["__builtins__"]["__import__"] = self._safe_import + spec.loader.exec_module(module) + else: + spec.loader.exec_module(module) + + if not hasattr(module, "New"): + return None + + instance = module.New() + self.loaded[clean_name] = { + "instance": instance, + "module": module, + "path": str(plugin_dir), + "name": clean_name, # 存储清理后的名称 + } + return self.loaded[clean_name] + + @staticmethod + def _safe_import(name: str, globals: dict = None, locals: dict = None, fromlist: tuple = (), level: int = 0): + """安全导入:只允许导入框架模块、标准库子集和插件自身模块""" + allowed_prefixes = ("oss.", "json.", "time.", "datetime.", "enum.", "typing.", "dataclasses.", "pathlib.", "mimetypes.", "http.", "threading.", "socket.", "asyncio.", "websockets.", "re.", "urllib.", "shutil.", "string.", "io.", "base64.", "hashlib.", "hmac.", "secrets.", "math.", "random.", "collections.", "functools.", "itertools.", "operator.", "copy.", "pprint.", "textwrap.", "unicodedata.", "struct.", "codecs.", "locale.", "gettext.", "logging.", "warnings.", "contextlib.", "abc.", "atexit.", "traceback.", "linecache.", "tokenize.", "keyword.", "ast.", "dis.", "inspect.", "types.", "__future__.", "importlib.", "pkgutil.", "sys.", "os.", "stat.", "glob.", "tempfile.", "fnmatch.", "csv.", "configparser.", "argparse.", "html.", "xml.", "email.", "mailbox.", "mimetypes.", "binascii.", "zlib.", "gzip.", "bz2.", "lzma.", "zipfile.", "tarfile.", "sqlite3.", "zlib.") + if any(name.startswith(p) for p in allowed_prefixes): + return __import__(name, globals, locals, fromlist, level) + # 允许相对导入(插件自身模块) + if level > 0: + return __import__(name, globals, locals, fromlist, level) + raise ImportError(f"插件不允许导入模块: {name}") + diff --git a/oss/plugin/manager.py b/oss/plugin/manager.py new file mode 100644 index 0000000..38fa86b --- /dev/null +++ b/oss/plugin/manager.py @@ -0,0 +1,33 @@ +"""插件管理器 - 只加载 plugin-loader""" +from typing import Any, Optional + +from oss.plugin.loader import PluginLoader + + +class PluginManager: + """管理基础插件""" + + def __init__(self): + self.loader = PluginLoader() + self.plugin_loader: Optional[Any] = None + + def load(self): + """加载基础插件""" + # 只加载 plugin-loader,其他都是可选的 + pl_info = self.loader.load_core_plugin("plugin-loader") + if pl_info: + self.plugin_loader = pl_info["instance"] + + def start(self): + """启动基础插件""" + if self.plugin_loader: + self.plugin_loader.init() + self.plugin_loader.start() + + def stop(self): + """停止基础插件""" + if self.plugin_loader: + try: + self.plugin_loader.stop() + except Exception: + pass diff --git a/oss/plugin/types.py b/oss/plugin/types.py new file mode 100644 index 0000000..9d503d8 --- /dev/null +++ b/oss/plugin/types.py @@ -0,0 +1,92 @@ +"""插件类型定义""" +from abc import ABC, abstractmethod +from typing import Any, Optional + + +# ========== 插件自定义类型注册 ========== +_plugin_types: dict[str, Any] = {} + + +def register_plugin_type(type_name: str, type_class: Any): + """注册插件自定义类型""" + _plugin_types[type_name] = type_class + + +def get_plugin_type(type_name: str) -> Optional[Any]: + """获取已注册的插件类型""" + return _plugin_types.get(type_name) + + +def list_plugin_types() -> dict[str, Any]: + """列出所有已注册的插件类型""" + return _plugin_types.copy() + + +# ========== HTTP 响应类型 ========== +class Response: + """HTTP 响应对象""" + def __init__(self, status: int = 200, headers: Optional[dict[str, str]] = None, body: str = ""): + self.status = status + self.headers = headers or {} + self.body = body + + +# ========== 插件数据类型 ========== +class Metadata: + """插件元数据""" + def __init__(self, name: str = "", version: str = "", author: str = "", description: str = ""): + self.name = name + self.version = version + self.author = author + self.description = description + + +class PluginConfig: + """插件配置""" + def __init__(self, enabled: bool = True, args: Optional[dict[str, Any]] = None): + self.enabled = enabled + self.args = args or {} + + +class Manifest: + """插件清单""" + def __init__(self, metadata: Optional[Metadata] = None, config: Optional[PluginConfig] = None, dependencies: Optional[list[str]] = None): + self.metadata = metadata or Metadata() + self.config = config or PluginConfig() + self.dependencies = dependencies or [] + + +# ========== 插件基类 ========== +class Plugin(ABC): + """插件基类""" + + @abstractmethod + def init(self, deps: Optional[dict[str, Any]] = None): + """初始化插件""" + ... + + @abstractmethod + def start(self): + """启动插件""" + ... + + @abstractmethod + def stop(self): + """停止插件""" + ... + + def meta(self) -> Manifest: + """获取插件元数据""" + return Manifest() + + def reload(self, config: Optional[dict[str, Any]] = None): + """热重载插件配置""" + pass + + def health(self) -> bool: + """健康检查""" + return True + + def stats(self) -> dict[str, Any]: + """获取插件统计信息""" + return {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aef25c4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "future-oss" +version = "1.0.0" +description = "Future OSS - 一切皆为插件的开发者工具运行时框架" +requires-python = ">=3.10" +dependencies = [ + "click>=8.0", + "pyyaml>=6.0", + "websockets>=12.0", +] + +[project.scripts] +oss = "oss.cli:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..552ae57 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +click>=8.0 +pyyaml>=6.0 +websockets>=12.0 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..048c81d --- /dev/null +++ b/start.bat @@ -0,0 +1,146 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal enabledelayedexpansion + +:: ═══════════════════════════════════════════════════════════ +:: FutureOSS 启动脚本 — Windows +:: 自动检测 Python / 依赖 / 守护 / 崩溃重启 +:: ═══════════════════════════════════════════════════════════ + +set "RED=[31m" +set "GREEN=[32m" +set "YELLOW=[33m" +set "CYAN=[36m" +set "WHITE=[37m" +set "BOLD=[1m" +set "NC=[0m" + +call :color_echo "BOLD" "CYAN" "" +echo ███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ +echo ██╔════╝ ██╔══██╗ ██╔══██╗ ██╔══██╗ ██╔══██╗██╔════╝ +echo █████╗ ██████╔╝ ██████╔╝ ██████╔╝ ██║ ██║██║ ███╗ +echo ██╔══╝ ██╔══██╗ ██╔══██╗ ██╔══██╗ ██║ ██║██║ ██║ +echo ██║ ██║ ██║ ██║ ██║ ██║ ██║ ██████╔╝╚██████╔╝ +echo ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ +call :color_echo "BOLD" "WHITE" "" 一切皆为插件 · 零编译热插拔 +call :color_echo "" "WHITE" "" https://gitee.com/starlight-apk/feature-oss +echo. + +:: ── 目录 ── +cd /d "%~dp0" +set "PYTHON_CMD=" +set "PIP_CMD=" + +:: ═══════════════════════════════════════════════════════════ +:: 1. 检查 Python +:: ═══════════════════════════════════════════════════════════ +call :section "环境检测" + +where python 2>nul && set "PYTHON_CMD=python" || ( + where python3 2>nul && set "PYTHON_CMD=python3" || ( + where py 2>nul && set "PYTHON_CMD=py" || ( + call :color_echo "" "YELLOW" "" [!] 未检测到 Python + echo. + echo 请安装 Python 3.10+ : + echo ^> https://www.python.org/downloads/ + echo. + echo 安装时请勾选 "Add Python to PATH" + echo. + pause + exit /b 1 + ) + ) +) + +for /f "tokens=*" %%i in ('%PYTHON_CMD% --version 2^>^&1') do set "PY_VER=%%i" +call :color_echo "" "GREEN" "" [✓] Python: %PY_VER% + +:: ═══════════════════════════════════════════════════════════ +:: 2. 虚拟环境 & 依赖 +:: ═══════════════════════════════════════════════════════════ +call :section "依赖安装" + +if not exist ".venv" ( + call :color_echo "" "CYAN" "" [i] 创建虚拟环境... + %PYTHON_CMD% -m venv .venv +) + +set "VENV_PYTHON=.venv\Scripts\python.exe" +set "VENV_PIP=.venv\Scripts\pip.exe" + +if exist "pyproject.toml" ( + call :color_echo "" "CYAN" "" [i] 安装项目依赖... + %VENV_PIP% install -e . -q 2>nul +) + +if exist "requirements.txt" ( + call :color_echo "" "CYAN" "" [i] 安装 requirements.txt... + %VENV_PIP% install -r requirements.txt -q 2>nul +) + +:: 核心依赖兜底 +for %%p in (click pyyaml websockets) do ( + %VENV_PYTHON% -c "import %%p" 2>nul || ( + call :color_echo "" "CYAN" "" [i] 安装 %%p ... + %VENV_PIP% install %%p -q 2>nul + ) +) + +call :color_echo "" "GREEN" "" [✓] 依赖就绪 + +:: ═══════════════════════════════════════════════════════════ +:: 3. 确保 data 目录 +:: ═══════════════════════════════════════════════════════════ +if not exist "data\html-render" mkdir "data\html-render" +if not exist "data\web-toolkit" mkdir "data\web-toolkit" +if not exist "data\plugin-storage" mkdir "data\plugin-storage" +if not exist "data\DCIM" mkdir "data\DCIM" +if not exist "data\pkg" mkdir "data\pkg" + +:: ═══════════════════════════════════════════════════════════ +:: 4. 启动 +:: ═══════════════════════════════════════════════════════════ +call :section "启动 FutureOSS" + +set "RESTART_DELAY=3" +set "RESTART_COUNT=0" + +:LOOP + echo. + call :color_echo "" "CYAN" "" [i] 启动服务... + echo. + %VENV_PYTHON% -m oss.cli serve + set "EXIT_CODE=!ERRORLEVEL!" + + if !EXIT_CODE! equ 0 ( + echo. + call :color_echo "" "GREEN" "" [✓] 服务正常退出 + goto :END + ) + + set /a RESTART_COUNT+=1 + echo. + call :color_echo "" "YELLOW" "" [!] 服务异常退出 (code: !EXIT_CODE!),!RESTART_DELAY!s 后重启... (第 !RESTART_COUNT! 次) + timeout /t !RESTART_DELAY! /nobreak >nul + + if !RESTART_DELAY! lss 30 set /a RESTART_DELAY=!RESTART_DELAY!*2 + + goto :LOOP + +:END +echo. +pause +exit /b 0 + +:: ── 辅助函数 ── +:color_echo + if "%~1" neq "" set /p "=^%BOLD%%~2%%NC%" /dev/null; then + echo "$cmd" + return + fi + done + return 1 +} + +PYTHON_CMD=$(find_python || true) + +if [[ -z "$PYTHON_CMD" ]]; then + warn "未检测到 Python,正在自动安装..." + if command -v apt-get &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq python3 python3-pip python3-venv + elif command -v yum &>/dev/null; then + sudo yum install -y python3 python3-pip + elif command -v pacman &>/dev/null; then + sudo pacman -Sy --noconfirm python python-pip + elif command -v brew &>/dev/null; then + brew install python + elif command -v apk &>/dev/null; then + apk add python3 py3-pip + else + err "无法自动安装 Python,请手动安装 Python 3.10+" + exit 1 + fi + PYTHON_CMD=$(find_python || true) + [[ -z "$PYTHON_CMD" ]] && { err "Python 安装失败"; exit 1; } +fi + +PY_VER=$($PYTHON_CMD --version 2>&1) +ok "Python: $PY_VER ($PYTHON_CMD)" + +# ═══════════════════════════════════════════════════════════ +# 2. 虚拟环境 & 依赖 +# ═══════════════════════════════════════════════════════════ +title "📚 依赖安装" + +VENV_DIR=".venv" +if [[ ! -d "$VENV_DIR" ]]; then + info "创建虚拟环境..." + $PYTHON_CMD -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" +PIP_CMD="$VENV_DIR/bin/pip" + +if [[ -f "pyproject.toml" ]]; then + info "安装项目依赖 (pyproject.toml)..." + $PIP_CMD install -e . -q 2>/dev/null || $PIP_CMD install -e . --break-system-packages -q 2>/dev/null || true +fi + +if [[ -f "requirements.txt" ]]; then + info "安装 requirements.txt..." + $PIP_CMD install -r requirements.txt -q 2>/dev/null || true +fi + +# 核心依赖兜底 +for pkg in click pyyaml websockets; do + $PYTHON_CMD -c "import $pkg" 2>/dev/null || { + info "安装 $pkg ..." + $PIP_CMD install "$pkg" -q 2>/dev/null || $PIP_CMD install "$pkg" --break-system-packages -q 2>/dev/null || true + } +done + +ok "依赖就绪" + +# ═══════════════════════════════════════════════════════════ +# 3. 确保 data 目录 +# ═══════════════════════════════════════════════════════════ +mkdir -p data/html-render data/web-toolkit data/plugin-storage data/DCIM data/pkg + +# ═══════════════════════════════════════════════════════════ +# 4. 启动 +# ═══════════════════════════════════════════════════════════ +title "🚀 启动 FutureOSS" + +if $DAEMON; then + title "🔒 守护模式" + LOG_FILE="logs/futureoss.log" + mkdir -p logs + PID_FILE="logs/futureoss.pid" + + if [[ -f "$PID_FILE" ]]; then + OLD_PID=$(cat "$PID_FILE") + if kill -0 "$OLD_PID" 2>/dev/null; then + warn "已有进程运行 (PID: $OLD_PID),正在停止..." + kill "$OLD_PID" 2>/dev/null || true + sleep 2 + fi + fi + + nohup $PYTHON_CMD -m oss.cli serve > "$LOG_FILE" 2>&1 & + NEW_PID=$! + echo "$NEW_PID" > "$PID_FILE" + ok "已启动守护进程 (PID: $NEW_PID)" + info "日志: $LOG_FILE" + info "停止: kill $(cat $PID_FILE) 或 bash start.sh stop" + sleep 2 + curl -s http://localhost:8080/health &>/dev/null && ok "服务就绪: http://localhost:8080" || warn "服务启动中,请稍候..." + exit 0 +fi + +# ── 前台模式 + 崩溃自动重启 ── +RESTART_DELAY=3 +MAX_RESTARTS=0 +RESTART_COUNT=0 + +run_server() { + $PYTHON_CMD -m oss.cli serve +} + +while true; do + run_server + EXIT_CODE=$? + + if [[ $EXIT_CODE -eq 0 ]]; then + ok "服务正常退出" + break + fi + + RESTART_COUNT=$((RESTART_COUNT + 1)) + warn "服务异常退出 (code: $EXIT_CODE),${RESTART_DELAY}s 后重启... (第 $RESTART_COUNT 次)" + sleep $RESTART_DELAY + + # 指数退避,最大 30s + if [[ $RESTART_DELAY -lt 30 ]]; then + RESTART_DELAY=$((RESTART_DELAY * 2)) + fi +done + +deactivate 2>/dev/null || true diff --git a/static/banner.svg b/static/banner.svg new file mode 100644 index 0000000..77d35a7 --- /dev/null +++ b/static/banner.svg @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Python 3.10+ Apache 2.0 Plugin Driven + + + + FutureOSS + 一切皆为插件的开发者工具运行时框架 + + + + 一个空壳框架,通过插件获得无限能力 + + + + + + + + 一切皆插件 + 协议、中间件、工具 + 所有功能以插件加载 + + + + + + 依赖自动解析 + 拓扑排序 + 循环检测 + 自动识别多级依赖 + + + + + + 企业级稳定 + 熔断、降级、重试 + 隔离 + 资源限制 + + + + + + 事件驱动 + 发布/订阅事件总线 + 40+ 种系统事件 + + + + + 快速开始 + + + + + + + + + $ git clone https://gitee.com/starlight-apk/feature-oss.git + $ cd feature-oss && bash start.sh + ✓ 服务已启动 → http://localhost:8080/ + + + + 核心特性 + + + + + + + 插件热插拔 + + + + + + 依赖自动解析 + + + + + + 事件总线 + + + + + + 完整配置 + + + + + + 协议适配 + + + + + + 熔断降级 + + + + + + 🏗️ 架构设计 + + + + + + 用户层 + + CLI │ HTTP API │ WebSocket │ 社区论坛 + + 插件层 + + http-api + + ws-api + + plugin-storage + + 核心层 + + PluginManager | EventBus | Config | Logger | Loader + + + + 文档 + + + + + + + + + + + + + + + + + + + + + + 所有文档都在本地 dock/ 目录中 + + + + 项目介绍 + + + 快速开始 + + + 插件开发 + + + 插件文档 + + + 包管理 + + + 配置参考 + + + 部署运维 + + + 社区与贡献 + + + + + + + 许可证 + Apache License 2.0 + Copyright 2026 Falck + + + + + Made with by Falck + https://gitee.com/starlight-apk + + + FUTURE OSS + EVERYTHING IS A PLUGIN + + + + diff --git a/store/@{Falck}/html-render/README.md b/store/@{Falck}/html-render/README.md new file mode 100644 index 0000000..144ed17 --- /dev/null +++ b/store/@{Falck}/html-render/README.md @@ -0,0 +1,41 @@ +# HTML 渲染服务 + +将存储在 plugin-storage 中的 HTML 页面映射到 8080 端口。 + +## 功能 + +- 从 plugin-storage 读取 HTML +- 自动注册路由到 web-toolkit +- 支持动态页面访问 +- 页面管理(存储/获取/删除/列出) + +## 使用 + +```python +html_render = plugin_mgr.get("html-render") + +# 存储 HTML 页面 +html_render.store_html("index", "

Hello World

") +html_render.store_html("about", "

About

") + +# 获取页面 +html = html_render.get_html("index") + +# 列出所有页面 +pages = html_render.list_pages() # ["index", "about"] + +# 删除页面 +html_render.delete_page("about") +``` + +## 访问 + +``` +http://localhost:8080/ → index 页面 +http://localhost:8080/about → about 页面 +``` + +## 依赖 + +- web-toolkit:Web 服务 +- plugin-storage:HTML 存储 diff --git a/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc b/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..a6fc9d1 Binary files /dev/null and b/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{Falck}/html-render/main.py b/store/@{Falck}/html-render/main.py new file mode 100644 index 0000000..5fa509e --- /dev/null +++ b/store/@{Falck}/html-render/main.py @@ -0,0 +1,99 @@ +"""HTML 渲染服务 - 通过 config.json 配置,统一文件入口""" +import json +from pathlib import Path +from oss.plugin.types import Plugin, register_plugin_type, Response + + +class HtmlRenderPlugin(Plugin): + """HTML 渲染插件 - 渲染服务由 html-render 提供""" + + def __init__(self): + self.http_api = None + self.storage = None # plugin-storage 入口 + self.config = {} + self.root_dir = None # 解析后的网站根目录 + + def init(self, deps: dict = None): + """初始化 - 读取 config.json 并解析网站根目录""" + self._load_config() + print(f"[html-render] 配置加载完成: root_dir={self.root_dir}") + + def start(self): + """启动 - 注册路由到 http-api,共享配置给 web-toolkit""" + # 注册首页路由 + if self.http_api and hasattr(self.http_api, 'router'): + self.http_api.router.get("/", self._serve_html) + print("[html-render] 已注册路由到 http-api") + else: + print("[html-render] http-api 未加载") + + # 将配置共享给 web-toolkit(通过 plugin-storage 的 DCIM 共享存储) + if self.storage: + shared = self.storage.get_shared() + shared.set_shared("html-render-config", { + "root_dir": str(self.root_dir), + "index_file": self.config.get("index_file", "index.html"), + "static_prefix": self.config.get("static_prefix", "/static"), + }) + print("[html-render] 配置已共享到 DCIM") + + def stop(self): + """停止""" + pass + + def set_http_api(self, instance): + """设置 http-api 实例""" + self.http_api = instance + + def set_plugin_storage(self, instance): + """设置 plugin-storage 实例(唯一文件读写入口)""" + self.storage = instance + + def _load_config(self): + """读取 config.json,解析根目录""" + config_path = Path("./data/html-render/config.json") + if not config_path.exists(): + print("[html-render] 警告: config.json 不存在,使用默认配置") + self.config = {"root_dir": "../website", "index_file": "index.html"} + else: + with open(config_path, "r", encoding="utf-8") as f: + self.config = json.load(f) + + # 解析根目录(相对于 config.json 的路径) + root_relative = self.config.get("root_dir", "../website") + self.root_dir = (config_path.parent / root_relative).resolve() + + def _serve_html(self, request): + """提供 HTML 页面 - 通过 plugin-storage 读取并注入静态资源路径""" + index_file = self.config.get("index_file", "index.html") + if self.storage: + storage = self.storage.get_storage("html-render") + if storage.file_exists(index_file): + content = storage.read_file(index_file) + if content: + # 注入静态资源路径(相对路径 → /website/ 前缀) + content = self._inject_static_paths(content) + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=content + ) + return Response(status=404, body="Not Found") + + def _inject_static_paths(self, html: str) -> str: + """将相对静态资源路径替换为 /website/ 前缀""" + import re + # href="css/xxx" → href="/website/css/xxx" + html = re.sub(r'(href\s*=\s*["\'])css/', r'\1/website/css/', html) + # src="js/xxx" → src="/website/js/xxx" + html = re.sub(r'(src\s*=\s*["\'])js/', r'\1/website/js/', html) + # src="logo.svg" → src="/website/logo.svg" + html = re.sub(r'(src\s*=\s*["\'])(?!https?://|/)([\w.-]+\.(svg|png|jpg|gif|ico|webp))', r'\1/website/\2', html) + return html + + +register_plugin_type("HtmlRenderPlugin", HtmlRenderPlugin) + + +def New(): + return HtmlRenderPlugin() diff --git a/store/@{Falck}/html-render/manifest.json b/store/@{Falck}/html-render/manifest.json new file mode 100644 index 0000000..71794de --- /dev/null +++ b/store/@{Falck}/html-render/manifest.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "name": "html-render", + "version": "1.0.0", + "author": "Falck", + "description": "HTML 渲染服务 - 提供 8080 端口的 HTML 页面服务", + "type": "utility" + }, + "config": { + "enabled": true, + "args": { + "html_dir": "./data/html-render" + } + }, + "dependencies": ["http-api", "plugin-storage"], + "permissions": ["http-api", "plugin-storage"] +} diff --git a/store/@{Falck}/web-toolkit/README.md b/store/@{Falck}/web-toolkit/README.md new file mode 100644 index 0000000..1141cfa --- /dev/null +++ b/store/@{Falck}/web-toolkit/README.md @@ -0,0 +1,71 @@ +# web-toolkit Web 工具包 + +提供静态文件服务、模板渲染、路由等 Web 开发工具。 + +## 功能 + +- **静态文件服务**:提供 HTML/CSS/JS/图片等静态文件 +- **模板引擎**:支持变量替换、条件判断、循环 +- **路由管理**:为 HTTP 和 TCP 服务器注册路由 +- **自动首页**:自动查找 index.html + +## 使用 + +```python +web = plugin_mgr.get("web-toolkit") + +# 设置目录 +web.set_static_dir("./public") +web.set_template_dir("./templates") + +# 添加自定义路由 +web.add_route("GET", "/api/hello", lambda req: { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": '{"message": "Hello"}' +}) + +# 渲染模板 +html = web.render_template("page.html", {"title": "My Page", "items": [1, 2, 3]}) +``` + +## 模板语法 + +```html + +

{{ title }}

+

{{ description }}

+ + +{% if show_content %} +
{{ content }}
+{% endif %} + + +
    +{% for item in items %} +
  • {{ item }}
  • +{% endfor %} +
+``` + +## 配置 + +```json +{ + "config": { + "args": { + "host": "0.0.0.0", + "port": 8080, + "static_dir": "./static", + "template_dir": "./templates", + "index_files": ["index.html", "index.htm"] + } + } +} +``` + +## 依赖 + +- http-api:HTTP 服务 +- http-tcp:TCP HTTP 服务 diff --git a/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..4a20a4b Binary files /dev/null and b/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000..7faa2ea Binary files /dev/null and b/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc new file mode 100644 index 0000000..5ee43f7 Binary files /dev/null and b/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc new file mode 100644 index 0000000..d3cd630 Binary files /dev/null and b/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/main.py b/store/@{Falck}/web-toolkit/main.py new file mode 100644 index 0000000..c1ae7ff --- /dev/null +++ b/store/@{Falck}/web-toolkit/main.py @@ -0,0 +1,158 @@ +"""Web 工具包 - 路由注册、静态文件服务、前端事件(不负责渲染)""" +import json +from pathlib import Path +from oss.plugin.types import Plugin, register_plugin_type, Response +from .router import WebRouter +from .static import StaticFileHandler +from .template import TemplateEngine + + +class WebToolkitPlugin(Plugin): + """Web 工具包插件 - 提供网站前端所有服务""" + + def __init__(self): + self.router = None + self.static_handler = None + self.template_engine = None + self.http_api = None + self.http_tcp = None + self.storage = None + self.config = {} # 从 config.json 读取 + self.root_dir = None + + def init(self, deps: dict = None): + """初始化 - 读取 config.json 配置""" + self.router = WebRouter() + self.template_engine = TemplateEngine() + self._load_config() + self.static_handler = StaticFileHandler(root=str(self.root_dir)) + print(f"[web-toolkit] 配置加载完成: root_dir={self.root_dir}") + + def start(self): + """启动""" + # 注册路由到 http-api + if self.http_api: + http_instance = self.http_api + if hasattr(http_instance, "router"): + # 精确路由先注册,参数化路由后注册 + http_instance.router.get( + self.config.get("website_prefix", "/website") + "/", + self._serve_website_index + ) + http_instance.router.get( + self.config.get("website_prefix", "/website") + "/:path", + self._serve_static + ) + http_instance.router.get( + self.config.get("static_prefix", "/static") + "/:path", + self._serve_static + ) + + # 注册路由到 http-tcp + if self.http_tcp: + tcp_instance = self.http_tcp + if hasattr(tcp_instance, "router"): + tcp_instance.router.get( + self.config.get("website_prefix", "/website") + "/", + self._serve_website_index + ) + tcp_instance.router.get( + self.config.get("website_prefix", "/website") + "/:path", + self._serve_static + ) + tcp_instance.router.get( + self.config.get("static_prefix", "/static") + "/:path", + self._serve_static + ) + + print("[web-toolkit] Web 工具包已启动") + + def stop(self): + """停止""" + pass + + def set_http_api(self, instance): + """设置 HTTP API 实例""" + self.http_api = instance + + def set_http_tcp(self, instance): + """设置 HTTP TCP 实例""" + self.http_tcp = instance + + def set_plugin_storage(self, instance): + """设置 plugin-storage 实例(唯一文件读写入口)""" + self.storage = instance + + def set_static_dir(self, path: str): + """设置静态文件目录""" + self.static_handler.set_root(path) + + def set_template_dir(self, path: str): + """设置模板目录""" + template_root = Path(path) + if template_root.exists(): + self.template_engine.set_root(str(template_root)) + + def _load_config(self): + """读取 config.json,解析网站根目录""" + config_path = Path("./data/web-toolkit/config.json") + if not config_path.exists(): + print("[web-toolkit] 警告: config.json 不存在,使用默认配置") + self.config = { + "root_dir": "../website", + "index_file": "index.html", + "static_prefix": "/static", + "website_prefix": "/website", + } + else: + with open(config_path, "r", encoding="utf-8") as f: + self.config = json.load(f) + + # 解析根目录(相对于 config.json 的路径) + root_relative = self.config.get("root_dir", "../website") + self.root_dir = (config_path.parent / root_relative).resolve() + + # 初始化模板引擎 + template_dir = self.config.get("template_dir", "") + if template_dir: + template_path = self.root_dir / template_dir + if template_path.exists(): + self.template_engine.set_root(str(template_path)) + + def _serve_website_index(self, request): + """提供 website 目录首页""" + index_file = self.config.get("index_file", "index.html") + if self.root_dir: + path = self.root_dir / index_file + if path.exists(): + content = path.read_text(encoding="utf-8") + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=content + ) + return Response(status=404, body="Index file not found") + + def _serve_static(self, request): + """提供静态文件""" + path = request.path + website_prefix = self.config.get("website_prefix", "/website") + static_prefix = self.config.get("static_prefix", "/static") + + if path.startswith(website_prefix + "/"): + filename = path[len(website_prefix) + 1:] + elif path.startswith(static_prefix + "/"): + filename = path[len(static_prefix) + 1:] + else: + filename = path.lstrip("/") + + if not filename: + return self._serve_website_index(request) + return self.static_handler.serve(filename) + + +register_plugin_type("WebToolkitPlugin", WebToolkitPlugin) + + +def New(): + return WebToolkitPlugin() diff --git a/store/@{Falck}/web-toolkit/manifest.json b/store/@{Falck}/web-toolkit/manifest.json new file mode 100644 index 0000000..5e6ad53 --- /dev/null +++ b/store/@{Falck}/web-toolkit/manifest.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "name": "web-toolkit", + "version": "1.0.0", + "author": "Falck", + "description": "Web 工具包 - 提供静态文件服务、模板渲染、路由等 Web 开发工具", + "type": "utility" + }, + "config": { + "enabled": true, + "args": { + "host": "0.0.0.0", + "port": 8080, + "static_dir": "./static", + "template_dir": "./templates", + "index_files": ["index.html", "index.htm"] + } + }, + "dependencies": ["http-api", "http-tcp", "plugin-storage"], + "permissions": ["http-api", "http-tcp", "json-codec", "plugin-storage"] +} diff --git a/store/@{Falck}/web-toolkit/router.py b/store/@{Falck}/web-toolkit/router.py new file mode 100644 index 0000000..7fbb571 --- /dev/null +++ b/store/@{Falck}/web-toolkit/router.py @@ -0,0 +1,63 @@ +"""Web 路由器""" +from typing import Callable, Optional, Any + + +class WebRoute: + """Web 路由""" + def __init__(self, method: str, path: str, handler: Callable): + self.method = method + self.path = path + self.handler = handler + + +class WebRouter: + """Web 路由器""" + + def __init__(self): + self.routes: list[WebRoute] = [] + + def add_route(self, method: str, path: str, handler: Callable): + """添加路由""" + self.routes.append(WebRoute(method, path, handler)) + + def get(self, path: str, handler: Callable): + """GET 路由""" + self.add_route("GET", path, handler) + + def post(self, path: str, handler: Callable): + """POST 路由""" + self.add_route("POST", path, handler) + + def put(self, path: str, handler: Callable): + """PUT 路由""" + self.add_route("PUT", path, handler) + + def delete(self, path: str, handler: Callable): + """DELETE 路由""" + self.add_route("DELETE", path, handler) + + def handle(self, request: dict) -> Optional[Any]: + """处理请求""" + method = request.get("method", "GET") + path = request.get("path", "/") + + for route in self.routes: + if route.method == method and self._match(route.path, path): + return route.handler(request) + + return None + + def _match(self, pattern: str, path: str) -> bool: + """路径匹配""" + if pattern == path: + return True + if ":" in pattern: + pattern_parts = pattern.strip("/").split("/") + path_parts = path.strip("/").split("/") + if len(pattern_parts) != len(path_parts): + return False + for p, a in zip(pattern_parts, path_parts): + if not p.startswith(":") and p != a: + return False + return True + return False diff --git a/store/@{Falck}/web-toolkit/static.py b/store/@{Falck}/web-toolkit/static.py new file mode 100644 index 0000000..af241df --- /dev/null +++ b/store/@{Falck}/web-toolkit/static.py @@ -0,0 +1,69 @@ +"""静态文件处理器""" +import os +import mimetypes +from pathlib import Path +from typing import Optional, Any + +from oss.plugin.types import Response + + +class StaticFileHandler: + """静态文件处理器""" + + def __init__(self, root: str = "./static"): + self.root = root + self._ensure_root() + + def _ensure_root(self): + """确保静态目录存在""" + Path(self.root).mkdir(parents=True, exist_ok=True) + + def set_root(self, path: str): + """设置静态文件根目录""" + self.root = path + self._ensure_root() + + def serve(self, filename: str) -> Optional[Response]: + """提供静态文件""" + file_path = Path(self.root) / filename + + # 安全检查:防止目录遍历 + try: + file_path.resolve().relative_to(Path(self.root).resolve()) + except ValueError: + return Response(status=403, body="Forbidden") + + if not file_path.exists() or not file_path.is_file(): + return Response(status=404, body="File not found") + + # 检测 MIME 类型 + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "application/octet-stream" + + # 读取文件内容 + try: + if content_type.startswith("text/") or content_type in ( + "application/json", "application/javascript", "application/xml" + ): + content = file_path.read_text(encoding="utf-8") + else: + content = file_path.read_bytes() + + return Response( + status=200, + headers={ + "Content-Type": content_type, + "Cache-Control": "public, max-age=3600", + }, + body=content, + ) + except Exception as e: + return Response(status=500, body=f"Error reading file: {e}") + + def list_files(self) -> list[str]: + """列出静态文件""" + root_path = Path(self.root) + if not root_path.exists(): + return [] + return [f.name for f in root_path.iterdir() if f.is_file()] diff --git a/store/@{Falck}/web-toolkit/template.py b/store/@{Falck}/web-toolkit/template.py new file mode 100644 index 0000000..4b126f8 --- /dev/null +++ b/store/@{Falck}/web-toolkit/template.py @@ -0,0 +1,144 @@ +"""模板引擎""" +import re +import ast +from pathlib import Path +from typing import Any, Optional + + +class TemplateEngine: + """简单模板引擎""" + + def __init__(self, root: str = "./templates"): + self.root = root + self._cache: dict[str, str] = {} + self._ensure_root() + + def _ensure_root(self): + """确保模板目录存在""" + Path(self.root).mkdir(parents=True, exist_ok=True) + + def set_root(self, path: str): + """设置模板根目录""" + self.root = path + self._ensure_root() + self._cache.clear() + + def render(self, name: str, context: dict[str, Any]) -> str: + """渲染模板""" + template = self._load_template(name) + return self._render_template(template, context) + + def _load_template(self, name: str) -> str: + """加载模板""" + if name in self._cache: + return self._cache[name] + + template_path = Path(self.root) / name + if not template_path.exists(): + raise FileNotFoundError(f"模板不存在: {name}") + + content = template_path.read_text(encoding="utf-8") + self._cache[name] = content + return content + + def _safe_eval(self, expression: str, context: dict) -> Any: + """安全评估表达式(仅允许简单的属性访问和比较)""" + # 只允许访问 context 中的变量 + # 支持的运算符: and, or, not, ==, !=, <, >, <=, >=, in + # 不允许函数调用、导入、属性访问等 + + # 使用 AST 解析并验证 + try: + tree = ast.parse(expression, mode='eval') + except SyntaxError: + return False + + # 验证 AST 节点 + if not self._validate_ast(tree.body[0].value, set(context.keys())): + return False + + # 在受限环境中评估 + try: + return eval(expression, {"__builtins__": {}}, context) + except Exception: + return False + + def _validate_ast(self, node: ast.AST, allowed_names: set) -> bool: + """验证 AST 只包含安全的操作""" + if isinstance(node, ast.Name): + return node.id in allowed_names or node.id in ('True', 'False', 'None') + elif isinstance(node, ast.Constant): + return True + elif isinstance(node, ast.BoolOp): + return all(self._validate_ast(v, allowed_names) for v in node.values) + elif isinstance(node, ast.Compare): + return (self._validate_ast(node.left, allowed_names) and + all(self._validate_ast(c, allowed_names) for c in node.comparators)) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + return self._validate_ast(node.operand, allowed_names) + elif isinstance(node, ast.Attribute): + # 不允许属性访问(防止绕过安全限制) + return False + elif isinstance(node, ast.Call): + # 不允许函数调用 + return False + elif isinstance(node, ast.Subscript): + # 允许简单的索引访问 + return (self._validate_ast(node.value, allowed_names) and + self._validate_ast(node.slice, allowed_names)) + return False + + def _render_template(self, template: str, context: dict[str, Any]) -> str: + """渲染模板内容""" + # 替换 {{ variable }} + def replace_var(match): + var_name = match.group(1).strip() + value = context.get(var_name, "") + if isinstance(value, (dict, list)): + import json + return json.dumps(value, ensure_ascii=False) + return str(value) + + result = re.sub(r'\{\{(.*?)\}\}', replace_var, template) + + # 处理 {% if condition %} ... {% endif %} + result = self._process_if(result, context) + + # 处理 {% for item in list %} ... {% endfor %} + result = self._process_for(result, context) + + return result + + def _process_if(self, template: str, context: dict) -> str: + """处理 if 条件""" + pattern = r'\{%\s*if\s+(.*?)\s*%\}(.*?){%\s*endif\s*%\}' + + def replace_if(match): + condition = match.group(1).strip() + content = match.group(2) + # 安全条件评估 + value = self._safe_eval(condition, context) + return content if value else "" + + return re.sub(pattern, replace_if, template, flags=re.DOTALL) + + def _process_for(self, template: str, context: dict) -> str: + """处理 for 循环""" + pattern = r'\{%\s*for\s+(\w+)\s+in\s+(\w+)\s*%\}(.*?){%\s*endfor\s*%\}' + + def replace_for(match): + item_name = match.group(1) + list_name = match.group(2) + content = match.group(3) + + items = context.get(list_name, []) + if not isinstance(items, list): + return "" + + result = "" + for item in items: + loop_context = {**context, item_name: item} + result += self._render_template(content, loop_context) + return result + + return re.sub(pattern, replace_for, template, flags=re.DOTALL) diff --git a/store/@{FutureOSS}/circuit-breaker/README.md b/store/@{FutureOSS}/circuit-breaker/README.md new file mode 100644 index 0000000..91cccd9 --- /dev/null +++ b/store/@{FutureOSS}/circuit-breaker/README.md @@ -0,0 +1,30 @@ +# circuit-breaker 熔断器 + +为插件提供熔断能力,防止级联失败。 + +## 功能 + +- 失败计数熔断 +- 状态:`closed` → `open` → `half-open` +- 可配置失败阈值 +- 自动恢复机制 + +## 状态机 + +``` +closed (正常) → open (熔断) → half-open (半开) → closed (恢复) +``` + +## 使用 + +```python +# 检查是否有熔断能力 +if "circuit_breaker" in capabilities: + breaker = extensions["_circuit_breaker_provider"] + cb = breaker.create("my-plugin", threshold=5) + + try: + result = cb.call(risky_function, arg1, arg2) + except Exception: + print("熔断器已触发") +``` diff --git a/store/@{FutureOSS}/circuit-breaker/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/circuit-breaker/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..58714dc Binary files /dev/null and b/store/@{FutureOSS}/circuit-breaker/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/circuit-breaker/main.py b/store/@{FutureOSS}/circuit-breaker/main.py new file mode 100644 index 0000000..d660651 --- /dev/null +++ b/store/@{FutureOSS}/circuit-breaker/main.py @@ -0,0 +1,70 @@ +"""熔断插件 - 为插件提供熔断能力""" +from oss.plugin.types import Plugin, register_plugin_type + + +class CircuitBreakerProvider: + """熔断能力提供者""" + + def __init__(self): + self.breakers: dict[str, "CircuitBreaker"] = {} + + def create(self, name: str, threshold: int = 5) -> "CircuitBreaker": + breaker = CircuitBreaker(name, threshold) + self.breakers[name] = breaker + return breaker + + def get(self, name: str): + return self.breakers.get(name) + + +class CircuitBreaker: + """熔断器""" + + def __init__(self, name: str, threshold: int = 5): + self.name = name + self.threshold = threshold + self.failures = 0 + self.state = "closed" # closed, open, half-open + + def call(self, func, *args, **kwargs): + if self.state == "open": + raise Exception(f"熔断器 '{self.name}' 已打开") + + try: + result = func(*args, **kwargs) + self.failures = 0 + self.state = "closed" + return result + except Exception as e: + self.failures += 1 + if self.failures >= self.threshold: + self.state = "open" + raise e + + +class CircuitBreakerPlugin(Plugin): + """熔断插件""" + + def __init__(self): + self.provider = CircuitBreakerProvider() + + def init(self, deps: dict = None): + pass + + def start(self): + pass + + def stop(self): + pass + + def get_provider(self): + return self.provider + + +# 注册类型 +register_plugin_type("CircuitBreakerProvider", CircuitBreakerProvider) +register_plugin_type("CircuitBreaker", CircuitBreaker) + + +def New(): + return CircuitBreakerPlugin() diff --git a/store/@{FutureOSS}/circuit-breaker/manifest.json b/store/@{FutureOSS}/circuit-breaker/manifest.json new file mode 100644 index 0000000..324bc58 --- /dev/null +++ b/store/@{FutureOSS}/circuit-breaker/manifest.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "name": "circuit-breaker", + "version": "1.0.0", + "author": "FutureOSS", + "description": "熔断器 - 为插件提供熔断能力", + "type": "extension" + }, + "config": { + "enabled": true, + "args": { + "default_threshold": 5 + } + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/dependency/README.md b/store/@{FutureOSS}/dependency/README.md new file mode 100644 index 0000000..11774d1 --- /dev/null +++ b/store/@{FutureOSS}/dependency/README.md @@ -0,0 +1,39 @@ +# dependency 依赖解析 + +插件依赖关系管理,使用拓扑排序确定加载顺序。 + +## 功能 + +- 拓扑排序(Kahn 算法) +- 循环依赖检测(DFS) +- 缺失依赖检测 +- 自动按依赖顺序加载插件 + +## 使用 + +```python +dep = dependency_plugin + +# 添加插件及其依赖 +dep.add_plugin("plugin-a", ["plugin-b", "plugin-c"]) +dep.add_plugin("plugin-b", []) +dep.add_plugin("plugin-c", ["plugin-b"]) + +# 解析依赖顺序 +order = dep.resolve() # 返回 ["plugin-b", "plugin-c", "plugin-a"] + +# 检查缺失依赖 +missing = dep.get_missing_deps() + +# 获取加载顺序 +order = dep.get_order() +``` + +## manifest.json 声明 + +```json +{ + "metadata": {...}, + "dependencies": ["lifecycle", "circuit-breaker"] +} +``` diff --git a/store/@{FutureOSS}/dependency/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/dependency/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..8b7d5ff Binary files /dev/null and b/store/@{FutureOSS}/dependency/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/dependency/main.py b/store/@{FutureOSS}/dependency/main.py new file mode 100644 index 0000000..dcea002 --- /dev/null +++ b/store/@{FutureOSS}/dependency/main.py @@ -0,0 +1,138 @@ +"""依赖解析插件 - 拓扑排序 + 循环依赖检测""" +from typing import Any, Optional + +from oss.plugin.types import Plugin, register_plugin_type + + +class DependencyError(Exception): + """依赖错误""" + pass + + +class DependencyResolver: + """依赖解析器""" + + def __init__(self): + self.graph: dict[str, list[str]] = {} # 插件名 -> 依赖列表 + + def add_plugin(self, name: str, dependencies: list[str]): + """添加插件及其依赖""" + self.graph[name] = dependencies + + def resolve(self) -> list[str]: + """解析依赖,返回拓扑排序后的插件列表 + + 例如:A 依赖 B,B 依赖 C + 图: A -> [B], B -> [C], C -> [] + 结果: [C, B, A] (先启动没有依赖的,再启动依赖它们的) + """ + # 检测循环依赖 + self._detect_cycles() + + # 拓扑排序 (Kahn 算法 - 反向) + # in_degree[name] = name 依赖的插件数量 + in_degree: dict[str, int] = {name: 0 for name in self.graph} + # 反向图: who_depends_on[dep] = [name1, name2, ...] (谁依赖 dep) + who_depends_on: dict[str, list[str]] = {name: [] for name in self.graph} + + for name, deps in self.graph.items(): + for dep in deps: + if dep in in_degree: + in_degree[name] += 1 # name 依赖 dep,所以 name 的入度 +1 + who_depends_on[dep].append(name) # dep 被 name 依赖 + + # 从没有依赖的插件开始 + queue = [name for name, degree in in_degree.items() if degree == 0] + result = [] + + while queue: + node = queue.pop(0) + result.append(node) + # node 已启动,减少依赖它的插件的入度 + for dependent in who_depends_on.get(node, []): + in_degree[dependent] -= 1 + if in_degree[dependent] == 0: + queue.append(dependent) + + if len(result) != len(self.graph): + raise DependencyError("无法解析依赖,可能存在循环依赖") + + return result + + def _detect_cycles(self): + """检测循环依赖""" + visited = set() + rec_stack = set() + + def dfs(node: str) -> bool: + visited.add(node) + rec_stack.add(node) + + for dep in self.graph.get(node, []): + if dep not in visited: + if dfs(dep): + return True + elif dep in rec_stack: + raise DependencyError(f"检测到循环依赖: {node} -> {dep}") + + rec_stack.remove(node) + return False + + for node in self.graph: + if node not in visited: + if dfs(node): + raise DependencyError(f"检测到循环依赖涉及: {node}") + + def get_missing(self) -> list[str]: + """获取缺失的依赖""" + all_deps = set() + for deps in self.graph.values(): + all_deps.update(deps) + all_plugins = set(self.graph.keys()) + return list(all_deps - all_plugins) + + +class DependencyPlugin(Plugin): + """依赖解析插件""" + + def __init__(self): + self.resolver = DependencyResolver() + self.plugin_deps: dict[str, list[str]] = {} + + def init(self, deps: dict = None): + """初始化""" + pass + + def start(self): + """启动""" + pass + + def stop(self): + """停止""" + pass + + def add_plugin(self, name: str, dependencies: list[str]): + """添加插件及其依赖""" + self.plugin_deps[name] = dependencies + self.resolver.add_plugin(name, dependencies) + + def resolve(self) -> list[str]: + """解析依赖顺序""" + return self.resolver.resolve() + + def get_missing_deps(self) -> list[str]: + """获取缺失的依赖""" + return self.resolver.get_missing() + + def get_order(self) -> list[str]: + """获取插件加载顺序""" + return self.resolve() + + +# 注册类型 +register_plugin_type("DependencyResolver", DependencyResolver) +register_plugin_type("DependencyError", DependencyError) + + +def New(): + return DependencyPlugin() diff --git a/store/@{FutureOSS}/dependency/manifest.json b/store/@{FutureOSS}/dependency/manifest.json new file mode 100644 index 0000000..e2ba06b --- /dev/null +++ b/store/@{FutureOSS}/dependency/manifest.json @@ -0,0 +1,15 @@ +{ + "metadata": { + "name": "dependency", + "version": "1.0.0", + "author": "FutureOSS", + "description": "依赖解析 - 拓扑排序 + 循环依赖检测", + "type": "core" + }, + "config": { + "enabled": true, + "args": {} + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/hot-reload/README.md b/store/@{FutureOSS}/hot-reload/README.md new file mode 100644 index 0000000..79b7df7 --- /dev/null +++ b/store/@{FutureOSS}/hot-reload/README.md @@ -0,0 +1,32 @@ +# hot-reload 热插拔 + +运行时加载、卸载、更新插件,无需重启服务。 + +## 功能 + +- 运行时加载新插件 +- 运行时卸载插件 +- 运行时更新插件(热重载) +- 自动监听文件变化(可选) +- 模块缓存清理 + +## 使用 + +```python +from pathlib import Path + +# 加载新插件 +hot_reload.load_plugin(Path("store/@{Author/new-plugin")) + +# 卸载插件 +hot_reload.unload_plugin("plugin-name") + +# 更新插件 +hot_reload.reload_plugin("plugin-name", Path("store/@{Author/plugin-name")) +``` + +## 注意事项 + +- 插件必须实现 `init()`, `start()`, `stop()` +- 卸载时会调用 `stop()` +- 更新时先 `stop()` 再 `init()` + `start()` diff --git a/store/@{FutureOSS}/hot-reload/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/hot-reload/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..b7dec9f Binary files /dev/null and b/store/@{FutureOSS}/hot-reload/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/hot-reload/main.py b/store/@{FutureOSS}/hot-reload/main.py new file mode 100644 index 0000000..2a36915 --- /dev/null +++ b/store/@{FutureOSS}/hot-reload/main.py @@ -0,0 +1,196 @@ +"""热插拔插件 - 运行时加载/卸载/更新插件""" +import sys +import time +import threading +from pathlib import Path +from typing import Any, Optional, Callable + +from oss.plugin.types import Plugin, register_plugin_type + + +class HotReloadError(Exception): + """热插拔错误""" + pass + + +class FileWatcher: + """文件监听器""" + + def __init__(self, watch_dirs: list[str], extensions: list[str], on_change: Callable): + self.watch_dirs = [Path(d) for d in watch_dirs] + self.extensions = extensions + self.on_change = on_change + self._running = False + self._thread: Optional[threading.Thread] = None + self._file_times: dict[str, float] = {} + self._scan_files() + + def _scan_files(self): + """扫描当前文件及其修改时间""" + for watch_dir in self.watch_dirs: + if watch_dir.exists(): + for f in watch_dir.rglob("*"): + if f.is_file() and f.suffix in self.extensions: + self._file_times[str(f)] = f.stat().st_mtime + + def start(self): + """开始监听""" + self._running = True + self._thread = threading.Thread(target=self._watch_loop, daemon=True) + self._thread.start() + + def stop(self): + """停止监听""" + self._running = False + if self._thread: + self._thread.join(timeout=5) + + def _watch_loop(self): + """监听循环""" + while self._running: + changed = [] + current_files = {} + + for watch_dir in self.watch_dirs: + if watch_dir.exists(): + for f in watch_dir.rglob("*"): + if f.is_file() and f.suffix in self.extensions: + fpath = str(f) + mtime = f.stat().st_mtime + current_files[fpath] = mtime + + # 新文件或修改过 + if fpath not in self._file_times: + changed.append(("new", f)) + elif mtime > self._file_times[fpath]: + changed.append(("modified", f)) + + # 检查删除的文件 + for fpath in self._file_times: + if fpath not in current_files: + changed.append(("deleted", Path(fpath))) + + if changed: + self._file_times = current_files + self.on_change(changed) + + time.sleep(1) + + +class HotReloadPlugin(Plugin): + """热插拔插件""" + + def __init__(self): + self.plugin_loader_instance = None + self.watcher: Optional[FileWatcher] = None + self.watch_dirs: list[str] = [] + self.watch_extensions: list[str] = [".py", ".json"] + + def init(self, deps: dict = None): + """初始化""" + pass + + def start(self): + """启动 - 自动开始监听默认目录""" + if not self.watch_dirs: + # 默认监听 store 目录 + self.watch_dirs = ["store"] + self.start_watching() + + def stop(self): + """停止""" + if self.watcher: + self.watcher.stop() + + def set_plugin_loader(self, plugin_loader): + """设置插件加载器实例""" + self.plugin_loader_instance = plugin_loader + + def set_watch_dirs(self, dirs: list[str]): + """设置监听目录""" + self.watch_dirs = dirs + + def start_watching(self): + """开始监听文件变化""" + if self.watch_dirs and self.plugin_loader_instance: + self.watcher = FileWatcher( + self.watch_dirs, + self.watch_extensions, + self._on_file_change + ) + self.watcher.start() + + def _on_file_change(self, changes: list[tuple[str, Path]]): + """文件变化回调""" + for change_type, fpath in changes: + # 只关心 main.py 和 manifest.json 的变化 + if fpath.name not in ("main.py", "manifest.json"): + continue + + plugin_dir = fpath.parent + plugin_name = plugin_dir.name + + try: + if change_type == "new": + self.load_plugin(plugin_dir) + elif change_type == "modified": + self.reload_plugin(plugin_name, plugin_dir) + elif change_type == "deleted": + self.unload_plugin(plugin_name) + except Exception as e: + print(f"[hot-reload] 处理变化失败: {e}") + + def load_plugin(self, plugin_dir: Path) -> bool: + """运行时加载插件""" + try: + plugin_name = plugin_dir.name + if plugin_name in self.plugin_loader_instance.plugins: + raise HotReloadError(f"插件已存在: {plugin_name}") + + self.plugin_loader_instance.load(plugin_dir) + info = self.plugin_loader_instance.plugins[plugin_name] + instance = info["instance"] + instance.init() + instance.start() + return True + except Exception as e: + raise HotReloadError(f"加载插件失败: {e}") + + def unload_plugin(self, plugin_name: str) -> bool: + """运行时卸载插件""" + try: + if plugin_name not in self.plugin_loader_instance.plugins: + raise HotReloadError(f"插件不存在: {plugin_name}") + + info = self.plugin_loader_instance.plugins[plugin_name] + instance = info["instance"] + instance.stop() + + # 从模块缓存中移除 + module = info.get("module") + if module and module.__name__ in sys.modules: + del sys.modules[module.__name__] + + del self.plugin_loader_instance.plugins[plugin_name] + return True + except Exception as e: + raise HotReloadError(f"卸载插件失败: {e}") + + def reload_plugin(self, plugin_name: str, plugin_dir: Path) -> bool: + """运行时更新插件""" + try: + # 先卸载 + self.unload_plugin(plugin_name) + # 再加载 + return self.load_plugin(plugin_dir) + except Exception as e: + raise HotReloadError(f"更新插件失败: {e}") + + +# 注册类型 +register_plugin_type("HotReloadError", HotReloadError) +register_plugin_type("FileWatcher", FileWatcher) + + +def New(): + return HotReloadPlugin() diff --git a/store/@{FutureOSS}/hot-reload/manifest.json b/store/@{FutureOSS}/hot-reload/manifest.json new file mode 100644 index 0000000..3856e3d --- /dev/null +++ b/store/@{FutureOSS}/hot-reload/manifest.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "name": "hot-reload", + "version": "1.0.0", + "author": "FutureOSS", + "description": "热插拔 - 运行时加载/卸载/更新插件", + "type": "utility" + }, + "config": { + "enabled": true, + "args": { + "watch_dirs": ["store", "./data/pkg"], + "watch_extensions": [".py", ".json"] + } + }, + "dependencies": [], + "permissions": ["plugin-loader"] +} diff --git a/store/@{FutureOSS}/http-api/README.md b/store/@{FutureOSS}/http-api/README.md new file mode 100644 index 0000000..d5c0c56 --- /dev/null +++ b/store/@{FutureOSS}/http-api/README.md @@ -0,0 +1,53 @@ +# http-api HTTP API 服务 + +提供 HTTP RESTful API 服务,支持路由、中间件等功能。 + +## 功能 + +- HTTP 服务器(GET/POST/PUT/DELETE) +- 路由匹配(支持参数路由 `:id`) +- 中间件链(CORS/日志/限流) +- 分散式布局(每个文件 < 200 行) + +## 路由使用 + +```python +# 在插件中获取 router +http_plugin = plugin_mgr.get("http-api") +router = http_plugin.router + +# 添加路由 +router.get("/health", lambda req: Response(status=200, body='{"status": "ok"}')) +router.get("/api/users", handle_users) +router.post("/api/users", handle_create_user) +router.get("/api/users/:id", handle_user_by_id) +``` + +## 中间件 + +```python +middleware = http_plugin.middleware + +# 添加自定义中间件 +class MyMiddleware(Middleware): + def process(self, ctx, next_fn): + # 前置处理 + resp = next_fn() # 继续执行 + # 后置处理 + return resp + +middleware.add(MyMiddleware()) +``` + +## 配置 + +```json +{ + "config": { + "args": { + "host": "0.0.0.0", + "port": 8080 + } + } +} +``` diff --git a/store/@{FutureOSS}/http-api/__pycache__/events.cpython-313.pyc b/store/@{FutureOSS}/http-api/__pycache__/events.cpython-313.pyc new file mode 100644 index 0000000..dc13894 Binary files /dev/null and b/store/@{FutureOSS}/http-api/__pycache__/events.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-api/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/http-api/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..aa0e514 Binary files /dev/null and b/store/@{FutureOSS}/http-api/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-api/__pycache__/middleware.cpython-313.pyc b/store/@{FutureOSS}/http-api/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000..3f37207 Binary files /dev/null and b/store/@{FutureOSS}/http-api/__pycache__/middleware.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-api/__pycache__/router.cpython-313.pyc b/store/@{FutureOSS}/http-api/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000..ee0b2dc Binary files /dev/null and b/store/@{FutureOSS}/http-api/__pycache__/router.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-api/__pycache__/server.cpython-313.pyc b/store/@{FutureOSS}/http-api/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..0b0b7e9 Binary files /dev/null and b/store/@{FutureOSS}/http-api/__pycache__/server.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-api/events.py b/store/@{FutureOSS}/http-api/events.py new file mode 100644 index 0000000..63ad4d3 --- /dev/null +++ b/store/@{FutureOSS}/http-api/events.py @@ -0,0 +1,58 @@ +"""HTTP 事件系统 - 请求/响应生命周期事件""" +from typing import Callable, Any, Optional +from dataclasses import dataclass, field + + +@dataclass +class HttpEvent: + """HTTP 事件""" + type: str # request, response, error, etc + request: Any = None + response: Any = None + error: Exception = None + context: dict[str, Any] = field(default_factory=dict) + + +class HttpEventBus: + """HTTP 事件总线""" + + def __init__(self): + self._handlers: dict[str, list[Callable]] = {} + + def on(self, event_type: str, handler: Callable): + """订阅事件""" + if event_type not in self._handlers: + self._handlers[event_type] = [] + self._handlers[event_type].append(handler) + + def off(self, event_type: str, handler: Callable): + """取消订阅""" + if event_type in self._handlers: + try: + self._handlers[event_type].remove(handler) + except ValueError: + pass + + def emit(self, event: HttpEvent): + """发布事件""" + handlers = self._handlers.get(event.type, []) + for handler in handlers: + try: + handler(event) + except Exception: + pass + + def clear(self): + """清空所有订阅""" + self._handlers.clear() + + +# 事件类型常量 +EVENT_REQUEST = "http.request" +EVENT_BEFORE_ROUTE = "http.before_route" +EVENT_AFTER_ROUTE = "http.after_route" +EVENT_BEFORE_HANDLER = "http.before_handler" +EVENT_AFTER_HANDLER = "http.after_handler" +EVENT_RESPONSE = "http.response" +EVENT_ERROR = "http.error" +EVENT_COMPLETE = "http.complete" diff --git a/store/@{FutureOSS}/http-api/main.py b/store/@{FutureOSS}/http-api/main.py new file mode 100644 index 0000000..ba9f798 --- /dev/null +++ b/store/@{FutureOSS}/http-api/main.py @@ -0,0 +1,68 @@ +"""HTTP API 插件 - 分散式布局""" +import json +from oss.plugin.types import Plugin, register_plugin_type +from .server import HttpServer, Response +from .router import Router +from .middleware import MiddlewareChain + + +class HttpApiPlugin(Plugin): + """HTTP API 插件""" + + def __init__(self): + self.server = None + self.router = Router() + self.middleware = MiddlewareChain() + + def init(self, deps: dict = None): + """初始化""" + # 注册基础路由 + self.router.get("/health", self._health_handler) + self.router.get("/api/server/info", self._server_info_handler) + self.router.get("/api/status", self._status_handler) + + self.server = HttpServer(self.router, self.middleware) + + def start(self): + """启动""" + self.server.start() + + def stop(self): + """停止""" + if self.server: + self.server.stop() + + def _health_handler(self, request): + """健康检查""" + return Response( + status=200, + body=json.dumps({"status": "ok", "service": "http-api"}), + headers={"Content-Type": "application/json"} + ) + + def _server_info_handler(self, request): + """服务器信息""" + return Response( + status=200, + body=json.dumps({ + "name": "FutureOSS HTTP API", + "version": "1.0.0", + "endpoints": ["/health", "/api/server/info", "/api/status"] + }), + headers={"Content-Type": "application/json"} + ) + + def _status_handler(self, request): + """状态检查""" + return Response( + status=200, + body=json.dumps({"status": "running", "plugins_loaded": True}), + headers={"Content-Type": "application/json"} + ) + + +register_plugin_type("HttpApiPlugin", HttpApiPlugin) + + +def New(): + return HttpApiPlugin() diff --git a/store/@{FutureOSS}/http-api/manifest.json b/store/@{FutureOSS}/http-api/manifest.json new file mode 100644 index 0000000..22d2e2a --- /dev/null +++ b/store/@{FutureOSS}/http-api/manifest.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "name": "http-api", + "version": "1.0.0", + "author": "FutureOSS", + "description": "HTTP API 服务 - 提供 RESTful API 和路由功能", + "type": "protocol" + }, + "config": { + "enabled": true, + "args": { + "host": "0.0.0.0", + "port": 8080 + } + }, + "dependencies": [], + "permissions": ["lifecycle", "circuit-breaker"] +} diff --git a/store/@{FutureOSS}/http-api/middleware.py b/store/@{FutureOSS}/http-api/middleware.py new file mode 100644 index 0000000..b1de2c4 --- /dev/null +++ b/store/@{FutureOSS}/http-api/middleware.py @@ -0,0 +1,57 @@ +"""中间件链 - CORS/日志/限流等""" +from typing import Callable, Optional, Any +from .server import Request, Response + + +class Middleware: + """中间件基类""" + def process(self, ctx: dict[str, Any], next_fn: Callable) -> Optional[Response]: + """处理请求""" + return None + + +class CorsMiddleware(Middleware): + """CORS 中间件""" + def process(self, ctx: dict, next_fn: Callable) -> Optional[Response]: + ctx["response_headers"] = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + } + return None + + +class LoggerMiddleware(Middleware): + """日志中间件""" + def process(self, ctx: dict, next_fn: Callable) -> Optional[Response]: + req = ctx.get("request") + if req: + print(f"[http-api] {req.method} {req.path}") + return None + + +class MiddlewareChain: + """中间件链""" + + def __init__(self): + self.middlewares: list[Middleware] = [] + self.add(LoggerMiddleware()) + self.add(CorsMiddleware()) + + def add(self, middleware: Middleware): + """添加中间件""" + self.middlewares.append(middleware) + + def run(self, ctx: dict[str, Any]) -> Optional[Response]: + """执行中间件链""" + idx = 0 + + def next_fn(): + nonlocal idx + if idx < len(self.middlewares): + mw = self.middlewares[idx] + idx += 1 + return mw.process(ctx, next_fn) + return None + + return next_fn() diff --git a/store/@{FutureOSS}/http-api/router.py b/store/@{FutureOSS}/http-api/router.py new file mode 100644 index 0000000..7c5b40b --- /dev/null +++ b/store/@{FutureOSS}/http-api/router.py @@ -0,0 +1,72 @@ +"""路由器 - 路径匹配和处理器分发""" +from typing import Callable, Optional +from .server import Request, Response + + +class Route: + """路由定义""" + def __init__(self, method: str, path: str, handler: Callable): + self.method = method + self.path = path + self.handler = handler + + +class Router: + """路由器""" + + def __init__(self): + self.routes: list[Route] = [] + + def add(self, method: str, path: str, handler: Callable): + """添加路由""" + self.routes.append(Route(method, path, handler)) + + def get(self, path: str, handler: Callable): + """GET 路由""" + self.add("GET", path, handler) + + def post(self, path: str, handler: Callable): + """POST 路由""" + self.add("POST", path, handler) + + def put(self, path: str, handler: Callable): + """PUT 路由""" + self.add("PUT", path, handler) + + def delete(self, path: str, handler: Callable): + """DELETE 路由""" + self.add("DELETE", path, handler) + + def handle(self, request: Request) -> Response: + """处理请求""" + for route in self.routes: + if route.method == request.method and self._match(route.path, request.path): + return route.handler(request) + return Response(status=404, body='{"error": "Not Found"}') + + def _match(self, pattern: str, path: str) -> bool: + """路径匹配""" + if pattern == path: + return True + if ":" in pattern: + pattern_parts = pattern.strip("/").split("/") + path_parts = path.strip("/").split("/") + + # 检查前缀是否匹配 + for i, p in enumerate(pattern_parts): + if i >= len(path_parts): + return False + if not p.startswith(":") and p != path_parts[i]: + return False + + # 如果最后一个 pattern 是 :path(通配符),允许更多路径段 + last_pattern = pattern_parts[-1] + if last_pattern.startswith(":") and len(path_parts) >= len(pattern_parts): + return True + + # 否则必须精确匹配段数 + if len(pattern_parts) != len(path_parts): + return False + + return True + return False diff --git a/store/@{FutureOSS}/http-api/server.py b/store/@{FutureOSS}/http-api/server.py new file mode 100644 index 0000000..155c798 --- /dev/null +++ b/store/@{FutureOSS}/http-api/server.py @@ -0,0 +1,110 @@ +"""HTTP 服务器核心""" +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Any + + +class Request: + """请求对象""" + def __init__(self, method, path, headers, body): + self.method = method + self.path = path + self.headers = headers + self.body = body + + +class Response: + """响应对象""" + def __init__(self, status=200, headers=None, body=""): + self.status = status + self.headers = headers or {} + self.body = body + + +class HttpServer: + """HTTP 服务器""" + + def __init__(self, router, middleware, host="0.0.0.0", port=8080): + self.host = host + self.port = port + self.router = router + self.middleware = middleware + self._server = None + self._thread = None + + def start(self): + """启动服务器""" + handler = self._create_handler() + self._server = HTTPServer((self.host, self.port), handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + print(f"[http-api] 服务器启动: {self.host}:{self.port}") + + def stop(self): + """停止服务器""" + if self._server: + self._server.shutdown() + print("[http-api] 服务器已停止") + + def _create_handler(self): + """创建请求处理器""" + router = self.router + middleware = self.middleware + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self._handle("GET") + + def do_POST(self): + self._handle("POST") + + def do_PUT(self): + self._handle("PUT") + + def do_DELETE(self): + self._handle("DELETE") + + def do_OPTIONS(self): + """处理 CORS 预检请求""" + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def _handle(self, method): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length else b"" + + req = Request( + method=method, + path=self.path, + headers=dict(self.headers), + body=body.decode("utf-8") + ) + + # 执行中间件 + ctx = {"request": req, "response": None} + result = middleware.run(ctx) + if result: + self._send_response(result) + return + + # 路由匹配 + resp = router.handle(req) + self._send_response(resp) + + def _send_response(self, resp: Response): + self.send_response(resp.status) + for k, v in resp.headers.items(): + self.send_header(k, v) + self.end_headers() + if isinstance(resp.body, str): + self.wfile.write(resp.body.encode("utf-8")) + else: + self.wfile.write(resp.body) + + def log_message(self, format, *args): + pass + + return Handler diff --git a/store/@{FutureOSS}/http-tcp/README.md b/store/@{FutureOSS}/http-tcp/README.md new file mode 100644 index 0000000..ea22683 --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/README.md @@ -0,0 +1,51 @@ +# http-tcp HTTP TCP 服务 + +提供基于 TCP 的 HTTP 协议实现。 + +## 功能 + +- 原始 TCP HTTP 服务器 +- 路由匹配 +- 中间件链(日志/CORS) +- 连接管理 +- 事件发布(通过 plugin-bridge) + +## 使用 + +```python +tcp = plugin_mgr.get("http-tcp") + +# 注册路由 +tcp.router.get("/api/status", lambda req: { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": '{"status": "ok"}' +}) + +# 获取客户端 +clients = tcp.server.get_clients() +``` + +## 事件 + +```python +bridge = plugin_mgr.get("plugin-bridge") +bus = bridge.event_bus + +bus.on("tcp.connect", lambda e: print(f"连接: {e.client.id}")) +bus.on("tcp.http.request", lambda e: print(f"请求: {e.context['request']['path']}")) +bus.on("tcp.disconnect", lambda e: print(f"断开: {e.client.id}")) +``` + +## 配置 + +```json +{ + "config": { + "args": { + "host": "0.0.0.0", + "port": 8082 + } + } +} +``` diff --git a/store/@{FutureOSS}/http-tcp/__pycache__/events.cpython-313.pyc b/store/@{FutureOSS}/http-tcp/__pycache__/events.cpython-313.pyc new file mode 100644 index 0000000..a42829d Binary files /dev/null and b/store/@{FutureOSS}/http-tcp/__pycache__/events.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-tcp/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/http-tcp/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..7733df6 Binary files /dev/null and b/store/@{FutureOSS}/http-tcp/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-tcp/__pycache__/middleware.cpython-313.pyc b/store/@{FutureOSS}/http-tcp/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000..a9568d6 Binary files /dev/null and b/store/@{FutureOSS}/http-tcp/__pycache__/middleware.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-tcp/__pycache__/router.cpython-313.pyc b/store/@{FutureOSS}/http-tcp/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000..33a8bc1 Binary files /dev/null and b/store/@{FutureOSS}/http-tcp/__pycache__/router.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-tcp/__pycache__/server.cpython-313.pyc b/store/@{FutureOSS}/http-tcp/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..7a2d158 Binary files /dev/null and b/store/@{FutureOSS}/http-tcp/__pycache__/server.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/http-tcp/events.py b/store/@{FutureOSS}/http-tcp/events.py new file mode 100644 index 0000000..e05d674 --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/events.py @@ -0,0 +1,21 @@ +"""HTTP TCP 事件定义""" +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class TcpEvent: + """TCP 事件""" + type: str + client: Any = None + data: bytes = b"" + context: dict[str, Any] = field(default_factory=dict) + + +# 事件类型常量 +EVENT_CONNECT = "tcp.connect" +EVENT_DISCONNECT = "tcp.disconnect" +EVENT_DATA = "tcp.data" +EVENT_REQUEST = "tcp.http.request" +EVENT_RESPONSE = "tcp.http.response" +EVENT_ERROR = "tcp.error" diff --git a/store/@{FutureOSS}/http-tcp/main.py b/store/@{FutureOSS}/http-tcp/main.py new file mode 100644 index 0000000..2ff8d58 --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/main.py @@ -0,0 +1,34 @@ +"""HTTP TCP 插件入口""" +from oss.plugin.types import Plugin, register_plugin_type +from .server import TcpHttpServer +from .router import TcpRouter +from .middleware import TcpMiddlewareChain + + +class HttpTcpPlugin(Plugin): + """HTTP TCP 插件""" + + def __init__(self): + self.server = None + self.router = TcpRouter() + self.middleware = TcpMiddlewareChain() + + def init(self, deps: dict = None): + """初始化""" + self.server = TcpHttpServer(self.router, self.middleware) + + def start(self): + """启动""" + self.server.start() + + def stop(self): + """停止""" + if self.server: + self.server.stop() + + +register_plugin_type("HttpTcpPlugin", HttpTcpPlugin) + + +def New(): + return HttpTcpPlugin() diff --git a/store/@{FutureOSS}/http-tcp/manifest.json b/store/@{FutureOSS}/http-tcp/manifest.json new file mode 100644 index 0000000..e36bf76 --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/manifest.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "name": "http-tcp", + "version": "1.0.0", + "author": "FutureOSS", + "description": "HTTP TCP 服务 - 基于 TCP 的 HTTP 协议实现", + "type": "protocol" + }, + "config": { + "enabled": true, + "args": { + "host": "0.0.0.0", + "port": 8082 + } + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/http-tcp/middleware.py b/store/@{FutureOSS}/http-tcp/middleware.py new file mode 100644 index 0000000..ac6ab7b --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/middleware.py @@ -0,0 +1,53 @@ +"""TCP HTTP 中间件链""" +from typing import Callable, Optional, Any + + +class TcpMiddleware: + """TCP 中间件基类""" + def process(self, request: dict, next_fn: Callable) -> Optional[dict]: + """处理请求""" + return next_fn() + + +class TcpLogMiddleware(TcpMiddleware): + """日志中间件""" + def process(self, request, next_fn): + print(f"[http-tcp] {request.get('method')} {request.get('path')}") + return next_fn() + + +class TcpCorsMiddleware(TcpMiddleware): + """CORS 中间件""" + def process(self, request, next_fn): + response = next_fn() + if response: + response.setdefault("headers", {}) + response["headers"]["Access-Control-Allow-Origin"] = "*" + return response + + +class TcpMiddlewareChain: + """TCP 中间件链""" + + def __init__(self): + self.middlewares: list[TcpMiddleware] = [] + self.add(TcpLogMiddleware()) + self.add(TcpCorsMiddleware()) + + def add(self, middleware: TcpMiddleware): + """添加中间件""" + self.middlewares.append(middleware) + + def run(self, request: dict) -> Optional[dict]: + """执行中间件链""" + idx = 0 + + def next_fn(): + nonlocal idx + if idx < len(self.middlewares): + mw = self.middlewares[idx] + idx += 1 + return mw.process(request, next_fn) + return None + + return next_fn() diff --git a/store/@{FutureOSS}/http-tcp/router.py b/store/@{FutureOSS}/http-tcp/router.py new file mode 100644 index 0000000..6f4b66c --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/router.py @@ -0,0 +1,63 @@ +"""TCP HTTP 路由器""" +from typing import Callable, Optional, Any + + +class TcpRoute: + """TCP HTTP 路由""" + def __init__(self, method: str, path: str, handler: Callable): + self.method = method + self.path = path + self.handler = handler + + +class TcpRouter: + """TCP HTTP 路由器""" + + def __init__(self): + self.routes: list[TcpRoute] = [] + + def add(self, method: str, path: str, handler: Callable): + """添加路由""" + self.routes.append(TcpRoute(method, path, handler)) + + def get(self, path: str, handler: Callable): + """GET 路由""" + self.add("GET", path, handler) + + def post(self, path: str, handler: Callable): + """POST 路由""" + self.add("POST", path, handler) + + def put(self, path: str, handler: Callable): + """PUT 路由""" + self.add("PUT", path, handler) + + def delete(self, path: str, handler: Callable): + """DELETE 路由""" + self.add("DELETE", path, handler) + + def handle(self, request: dict) -> dict: + """处理请求""" + method = request.get("method", "GET") + path = request.get("path", "/") + + for route in self.routes: + if route.method == method and self._match(route.path, path): + return route.handler(request) + + return {"status": 404, "headers": {}, "body": "Not Found"} + + def _match(self, pattern: str, path: str) -> bool: + """路径匹配""" + if pattern == path: + return True + if ":" in pattern: + pattern_parts = pattern.strip("/").split("/") + path_parts = path.strip("/").split("/") + if len(pattern_parts) != len(path_parts): + return False + for p, a in zip(pattern_parts, path_parts): + if not p.startswith(":") and p != a: + return False + return True + return False diff --git a/store/@{FutureOSS}/http-tcp/server.py b/store/@{FutureOSS}/http-tcp/server.py new file mode 100644 index 0000000..1d16515 --- /dev/null +++ b/store/@{FutureOSS}/http-tcp/server.py @@ -0,0 +1,193 @@ +"""TCP HTTP 服务器核心""" +import socket +import threading +import re +from typing import Any, Callable, Optional +from .events import TcpEvent, EVENT_CONNECT, EVENT_DISCONNECT, EVENT_DATA, EVENT_REQUEST, EVENT_RESPONSE + + +class TcpClient: + """TCP 客户端连接""" + + def __init__(self, conn: socket.socket, address: tuple): + self.conn = conn + self.address = address + self.id = f"{address[0]}:{address[1]}" + + def send(self, data: bytes): + """发送数据""" + self.conn.sendall(data) + + def close(self): + """关闭连接""" + self.conn.close() + + +class TcpHttpServer: + """TCP HTTP 服务器""" + + def __init__(self, router, middleware, event_bus=None, host="0.0.0.0", port=8082): + self.host = host + self.port = port + self.router = router + self.middleware = middleware + self.event_bus = event_bus + self._server = None + self._thread = None + self._running = False + self._clients: dict[str, TcpClient] = {} + + def start(self): + """启动服务器""" + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind((self.host, self.port)) + self._server.listen(128) + self._running = True + self._thread = threading.Thread(target=self._accept_loop, daemon=True) + self._thread.start() + print(f"[http-tcp] 服务器启动: {self.host}:{self.port}") + + def _accept_loop(self): + """接受连接循环""" + while self._running: + try: + conn, address = self._server.accept() + client = TcpClient(conn, address) + self._clients[client.id] = client + + # 触发连接事件 + if self.event_bus: + self.event_bus.emit(TcpEvent(type=EVENT_CONNECT, client=client)) + + # 启动处理线程 + t = threading.Thread(target=self._handle_client, args=(client,), daemon=True) + t.start() + except Exception as e: + if self._running: + print(f"[http-tcp] 接受连接失败: {e}") + + def _handle_client(self, client: TcpClient): + """处理客户端请求""" + buffer = b"" + try: + while self._running: + data = client.conn.recv(4096) + if not data: + break + buffer += data + + # 检查 HTTP 请求是否完整 + if b"\r\n\r\n" in buffer: + request = self._parse_request(buffer) + if request: + # 触发请求事件 + if self.event_bus: + self.event_bus.emit(TcpEvent( + type=EVENT_REQUEST, + client=client, + context={"request": request} + )) + + # 路由处理 + response = self.router.handle(request) + + # 发送响应 + response_bytes = self._format_response(response) + client.send(response_bytes) + + # 触发响应事件 + if self.event_bus: + self.event_bus.emit(TcpEvent( + type=EVENT_RESPONSE, + client=client, + data=response_bytes + )) + + buffer = b"" + + except Exception as e: + if self.event_bus: + self.event_bus.emit(TcpEvent(type=EVENT_ERROR, client=client, context={"error": str(e)})) + finally: + del self._clients[client.id] + client.close() + if self.event_bus: + self.event_bus.emit(TcpEvent(type=EVENT_DISCONNECT, client=client)) + + def _parse_request(self, data: bytes) -> Optional[dict]: + """解析 HTTP 请求""" + try: + text = data.decode("utf-8", errors="replace") + lines = text.split("\r\n") + if not lines: + return None + + # 解析请求行 + match = re.match(r'(\w+)\s+(\S+)\s+HTTP/(\d\.\d)', lines[0]) + if not match: + return None + + method, path, version = match.groups() + + # 解析头 + headers = {} + body_start = 0 + for i, line in enumerate(lines[1:], 1): + if line == "": + body_start = i + 1 + break + if ":" in line: + key, value = line.split(":", 1) + headers[key.strip()] = value.strip() + + # 解析体 + content_length = int(headers.get("Content-Length", 0)) + body = "\r\n".join(lines[body_start:]) if body_start else "" + + return { + "method": method, + "path": path, + "version": version, + "headers": headers, + "body": body, + } + except Exception: + return None + + def _format_response(self, response: dict) -> bytes: + """格式化 HTTP 响应""" + status = response.get("status", 200) + headers = response.get("headers", {}) + body = response.get("body", "") + + status_text = {200: "OK", 404: "Not Found", 500: "Internal Server Error"}.get(status, "OK") + + response_lines = [ + f"HTTP/1.1 {status} {status_text}", + ] + + if "Content-Type" not in headers: + headers["Content-Type"] = "text/plain; charset=utf-8" + headers["Content-Length"] = str(len(body)) + + for key, value in headers.items(): + response_lines.append(f"{key}: {value}") + + response_lines.append("") + response_lines.append(body) + + return "\r\n".join(response_lines).encode("utf-8") + + def stop(self): + """停止服务器""" + self._running = False + for client in self._clients.values(): + client.close() + if self._server: + self._server.close() + print("[http-tcp] 服务器已停止") + + def get_clients(self) -> list[TcpClient]: + """获取所有客户端""" + return list(self._clients.values()) diff --git a/store/@{FutureOSS}/json-codec/README.md b/store/@{FutureOSS}/json-codec/README.md new file mode 100644 index 0000000..fd22b8d --- /dev/null +++ b/store/@{FutureOSS}/json-codec/README.md @@ -0,0 +1,83 @@ +# json-codec JSON 编解码器 + +提供插件间 JSON 数据的编码、解码和验证功能。 + +## 功能 + +- **JSON 编码**: Python 对象 → JSON 字符串 +- **JSON 解码**: JSON 字符串 → Python 对象 +- **Schema 验证**: 验证 JSON 数据结构 +- **自定义类型**: 支持注册自定义类型编解码器 + +## 基本使用 + +```python +codec = plugin_mgr.get("json-codec") + +# 编码 +data = {"name": "test", "count": 42} +json_str = codec.encode(data) +# '{"name": "test", "count": 42}' + +# 编码(格式化) +json_pretty = codec.encode(data, pretty=True) +# '{\n "name": "test",\n "count": 42\n}' + +# 解码 +parsed = codec.decode(json_str) +# {"name": "test", "count": 42} +``` + +## HTTP 响应处理 + +```python +# 在 http-api 插件中使用 +router.get("/api/users", lambda req: Response( + status=200, + headers={"Content-Type": "application/json"}, + body=codec.encode({"users": [...]}) +)) +``` + +## Schema 验证 + +```python +# 注册 schema +codec.register_schema("user", { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + "age": {"type": "number"} + } +}) + +# 验证数据 +user_data = {"name": "test", "email": "test@example.com"} +is_valid = codec.validate(user_data, "user") +``` + +## 自定义类型 + +```python +from datetime import datetime + +# 注册自定义编码器 +codec.serializer.register_encoder(datetime, lambda dt: dt.isoformat()) + +# 使用 +data = {"created_at": datetime.now()} +json_str = codec.encode(data) +``` + +## 错误处理 + +```python +from oss.plugin.types import JsonCodecError + +try: + result = codec.decode("invalid json") +except JsonCodecError as e: + print(f"解码失败: {e}") +``` diff --git a/store/@{FutureOSS}/json-codec/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/json-codec/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..44b40b2 Binary files /dev/null and b/store/@{FutureOSS}/json-codec/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/json-codec/main.py b/store/@{FutureOSS}/json-codec/main.py new file mode 100644 index 0000000..9f9dd55 --- /dev/null +++ b/store/@{FutureOSS}/json-codec/main.py @@ -0,0 +1,161 @@ +"""JSON 编解码器 - 插件间 JSON 数据处理""" +import json +from typing import Any, Callable, Optional +from datetime import datetime + +from oss.plugin.types import Plugin, register_plugin_type + + +class JsonCodecError(Exception): + """JSON 编解码错误""" + pass + + +class JsonSerializer: + """JSON 序列化器""" + + def __init__(self): + self._custom_encoders: dict[type, Callable] = {} + + def register_encoder(self, type_class: type, encoder: Callable): + """注册自定义类型编码器""" + self._custom_encoders[type_class] = encoder + + def encode(self, data: Any, pretty: bool = False) -> str: + """编码为 JSON 字符串""" + def default_handler(obj): + if isinstance(obj, datetime): + return obj.isoformat() + for type_class, encoder in self._custom_encoders.items(): + if isinstance(obj, type_class): + return encoder(obj) + raise TypeError(f"无法序列化类型: {type(obj).__name__}") + + if pretty: + return json.dumps(data, ensure_ascii=False, indent=2, default=default_handler) + return json.dumps(data, ensure_ascii=False, default=default_handler) + + def encode_to_bytes(self, data: Any) -> bytes: + """编码为字节""" + return self.encode(data).encode("utf-8") + + +class JsonDeserializer: + """JSON 反序列化器""" + + def __init__(self): + self._custom_decoders: dict[str, Callable] = {} + + def register_decoder(self, type_name: str, decoder: Callable): + """注册自定义类型解码器""" + self._custom_decoders[type_name] = decoder + + def decode(self, text: str) -> Any: + """解码 JSON 字符串""" + try: + return json.loads(text) + except json.JSONDecodeError as e: + raise JsonCodecError(f"JSON 解码失败: {e}") + + def decode_bytes(self, data: bytes) -> Any: + """解码字节""" + return self.decode(data.decode("utf-8")) + + def decode_file(self, path: str) -> Any: + """解码 JSON 文件""" + with open(path, "r", encoding="utf-8") as f: + return self.decode(f.read()) + + +class JsonValidator: + """JSON 验证器""" + + def __init__(self): + self._schemas: dict[str, dict] = {} + + def register_schema(self, name: str, schema: dict): + """注册 schema""" + self._schemas[name] = schema + + def validate(self, data: Any, schema_name: str) -> bool: + """验证数据是否符合 schema""" + if schema_name not in self._schemas: + raise JsonCodecError(f"未知的 schema: {schema_name}") + return self._check_schema(data, self._schemas[schema_name]) + + def _check_schema(self, data: Any, schema: dict) -> bool: + """检查 schema 匹配""" + schema_type = schema.get("type") + if schema_type == "object": + if not isinstance(data, dict): + return False + required = schema.get("required", []) + for field in required: + if field not in data: + return False + properties = schema.get("properties", {}) + for key, value in data.items(): + if key in properties: + if not self._check_schema(value, properties[key]): + return False + return True + elif schema_type == "array": + if not isinstance(data, list): + return False + items_schema = schema.get("items", {}) + return all(self._check_schema(item, items_schema) for item in data) + elif schema_type == "string": + return isinstance(data, str) + elif schema_type == "number": + return isinstance(data, (int, float)) + elif schema_type == "boolean": + return isinstance(data, bool) + return True + + +class JsonCodecPlugin(Plugin): + """JSON 编解码器插件""" + + def __init__(self): + self.serializer = JsonSerializer() + self.deserializer = JsonDeserializer() + self.validator = JsonValidator() + + def init(self, deps: dict = None): + """初始化""" + pass + + def start(self): + """启动""" + print("[json-codec] JSON 编解码器已启动") + + def stop(self): + """停止""" + pass + + def encode(self, data: Any, pretty: bool = False) -> str: + """编码 JSON""" + return self.serializer.encode(data, pretty) + + def decode(self, text: str) -> Any: + """解码 JSON""" + return self.deserializer.decode(text) + + def validate(self, data: Any, schema_name: str) -> bool: + """验证 JSON schema""" + return self.validator.validate(data, schema_name) + + def register_schema(self, name: str, schema: dict): + """注册 schema""" + self.validator.register_schema(name, schema) + + +# 注册类型 +register_plugin_type("JsonSerializer", JsonSerializer) +register_plugin_type("JsonDeserializer", JsonDeserializer) +register_plugin_type("JsonValidator", JsonValidator) +register_plugin_type("JsonCodecError", JsonCodecError) + + +def New(): + return JsonCodecPlugin() diff --git a/store/@{FutureOSS}/json-codec/manifest.json b/store/@{FutureOSS}/json-codec/manifest.json new file mode 100644 index 0000000..9af4d93 --- /dev/null +++ b/store/@{FutureOSS}/json-codec/manifest.json @@ -0,0 +1,15 @@ +{ + "metadata": { + "name": "json-codec", + "version": "1.0.0", + "author": "FutureOSS", + "description": "JSON 编解码器 - 插件间 JSON 数据处理和验证", + "type": "utility" + }, + "config": { + "enabled": true, + "args": {} + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/lifecycle/README.md b/store/@{FutureOSS}/lifecycle/README.md new file mode 100644 index 0000000..b68dfea --- /dev/null +++ b/store/@{FutureOSS}/lifecycle/README.md @@ -0,0 +1,30 @@ +# lifecycle 生命周期管理 + +管理插件的状态转换和钩子函数。 + +## 功能 + +- 状态机:`pending` → `running` → `stopped` +- 支持状态转换验证 +- 提供生命周期钩子: + - `before_start` + - `after_start` + - `before_stop` + - `after_stop` +- 支持扩展能力注入 + +## 状态转换 + +``` +pending → running → stopped + ↕ + (可重启) +``` + +## 使用 + +```python +lc = lifecycle_plugin.create("my-plugin") +lc.on("after_start", lambda: print("started")) +lc.start() +``` diff --git a/store/@{FutureOSS}/lifecycle/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/lifecycle/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c53f179 Binary files /dev/null and b/store/@{FutureOSS}/lifecycle/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/lifecycle/main.py b/store/@{FutureOSS}/lifecycle/main.py new file mode 100644 index 0000000..e71d20a --- /dev/null +++ b/store/@{FutureOSS}/lifecycle/main.py @@ -0,0 +1,150 @@ +"""生命周期插件 - 管理插件生命周期状态""" +from enum import Enum +from typing import Optional, Callable, Any + +from oss.plugin.types import Plugin, register_plugin_type + + +class LifecycleState(str, Enum): + """生命周期状态""" + PENDING = "pending" + RUNNING = "running" + STOPPED = "stopped" + + +class LifecycleError(Exception): + """生命周期错误""" + pass + + +class Lifecycle: + """生命周期管理器""" + + VALID_TRANSITIONS = { + LifecycleState.PENDING: [LifecycleState.RUNNING], + LifecycleState.RUNNING: [LifecycleState.STOPPED], + LifecycleState.STOPPED: [LifecycleState.RUNNING], + } + + def __init__(self, name: str): + self.name = name + self.state = LifecycleState.PENDING + self._hooks: dict[str, list[Callable]] = { + "before_start": [], + "after_start": [], + "before_stop": [], + "after_stop": [], + } + self._extensions: dict[str, Any] = {} # 扩展能力 + + def add_extension(self, name: str, extension: Any): + """添加扩展能力""" + self._extensions[name] = extension + + def get_extension(self, name: str) -> Optional[Any]: + """获取扩展能力""" + return self._extensions.get(name) + + def transition(self, target_state: LifecycleState): + """状态转换""" + if target_state not in self.VALID_TRANSITIONS.get(self.state, []): + raise LifecycleError( + f"插件 '{self.name}' 无法从 {self.state.value} 转换到 {target_state.value}" + ) + + old_state = self.state + self.state = target_state + + def start(self): + """启动""" + for hook in self._hooks["before_start"]: + hook(self) + self.transition(LifecycleState.RUNNING) + for hook in self._hooks["after_start"]: + hook(self) + + def stop(self): + """停止""" + for hook in self._hooks["before_stop"]: + hook(self) + self.transition(LifecycleState.STOPPED) + for hook in self._hooks["after_stop"]: + hook(self) + + def restart(self): + """重启""" + if self.state == LifecycleState.RUNNING: + self.stop() + self.start() + + def on(self, event: str, hook: Callable): + """注册钩子""" + if event in self._hooks: + self._hooks[event].append(hook) + + def is_running(self) -> bool: + return self.state == LifecycleState.RUNNING + + def is_stopped(self) -> bool: + return self.state == LifecycleState.STOPPED + + def is_pending(self) -> bool: + return self.state == LifecycleState.PENDING + + def __repr__(self): + return f"Lifecycle({self.name}, state={self.state.value})" + + +class LifecyclePlugin(Plugin): + """生命周期插件""" + + def __init__(self): + self.lifecycles: dict[str, Lifecycle] = {} + + def init(self, deps: dict = None): + """初始化""" + pass + + def start(self): + """启动""" + pass + + def stop(self): + """停止""" + pass + + def create(self, name: str) -> Lifecycle: + """创建生命周期""" + lifecycle = Lifecycle(name) + self.lifecycles[name] = lifecycle + return lifecycle + + def get(self, name: str) -> Optional[Lifecycle]: + """获取生命周期""" + return self.lifecycles.get(name) + + def start_all(self): + """启动所有""" + for lc in self.lifecycles.values(): + try: + lc.start() + except LifecycleError: + pass + + def stop_all(self): + """停止所有""" + for lc in self.lifecycles.values(): + try: + lc.stop() + except LifecycleError: + pass + + +# 注册类型 +register_plugin_type("Lifecycle", Lifecycle) +register_plugin_type("LifecycleState", LifecycleState) +register_plugin_type("LifecycleError", LifecycleError) + + +def New(): + return LifecyclePlugin() diff --git a/store/@{FutureOSS}/lifecycle/manifest.json b/store/@{FutureOSS}/lifecycle/manifest.json new file mode 100644 index 0000000..182dc7f --- /dev/null +++ b/store/@{FutureOSS}/lifecycle/manifest.json @@ -0,0 +1,15 @@ +{ + "metadata": { + "name": "lifecycle", + "version": "1.0.0", + "author": "FutureOSS", + "description": "生命周期管理 - 管理插件的状态转换和钩子", + "type": "core" + }, + "config": { + "enabled": true, + "args": {} + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/pkg/README.md b/store/@{FutureOSS}/pkg/README.md new file mode 100644 index 0000000..4cc3963 --- /dev/null +++ b/store/@{FutureOSS}/pkg/README.md @@ -0,0 +1,43 @@ +# pkg 包管理 + +插件的搜索、安装、卸载和更新功能。 + +## 功能 + +- 从远程仓库搜索插件 +- 下载并安装到 `./data/pkg/` 目录 +- 卸载已安装的插件 +- 更新单个或所有插件 +- 维护已安装插件列表 + +## 使用 + +```python +pm = pkg_plugin.manager + +# 搜索 +results = pm.search("keyword") + +# 安装 +pm.install("plugin-name") +pm.install("plugin-name", version="1.0.0") + +# 卸载 +pm.uninstall("plugin-name") + +# 更新 +pm.update() # 更新所有 +pm.update("plugin-name") # 更新单个 + +# 列出已安装 +installed = pm.list_installed() +``` + +## 安装位置 + +``` +./data/pkg/ +└── <插件名>/ + ├── main.py + └── manifest.json +``` diff --git a/store/@{FutureOSS}/pkg/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/pkg/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..f181467 Binary files /dev/null and b/store/@{FutureOSS}/pkg/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/pkg/main.py b/store/@{FutureOSS}/pkg/main.py new file mode 100644 index 0000000..a724478 --- /dev/null +++ b/store/@{FutureOSS}/pkg/main.py @@ -0,0 +1,201 @@ +"""包管理插件 - 搜索、安装、卸载、更新插件""" +import os +import json +import shutil +import urllib.request +import urllib.parse +from pathlib import Path +from typing import Any, Optional + +from oss.plugin.types import Plugin, register_plugin_type + + +# 远程仓库地址(可配置) +DEFAULT_REGISTRY = "https://gitee.com/starlight-apk/future-oss-pkg/raw/main" + +# 插件安装目录 +PKG_DIR = Path("./data/pkg") + + +class PackageInfo: + """包信息""" + def __init__(self): + self.name: str = "" + self.version: str = "" + self.author: str = "" + self.description: str = "" + self.download_url: str = "" + self.dependencies: list[str] = [] + + +class PackageManager: + """包管理器""" + + def __init__(self): + self.registry = DEFAULT_REGISTRY + self.index_cache: dict[str, PackageInfo] = {} + self.installed: dict[str, dict[str, Any]] = {} + self._load_installed() + + def _load_installed(self): + """加载已安装的包""" + if not PKG_DIR.exists(): + return + for pkg_dir in PKG_DIR.iterdir(): + if pkg_dir.is_dir(): + manifest = pkg_dir / "manifest.json" + if manifest.exists(): + with open(manifest, "r", encoding="utf-8") as f: + self.installed[pkg_dir.name] = json.load(f) + + def search(self, query: str = "") -> list[PackageInfo]: + """搜索可用的包""" + # 从远程仓库获取包索引 + index_url = f"{self.registry}/index.json" + try: + with urllib.request.urlopen(index_url, timeout=10) as resp: + index = json.loads(resp.read().decode("utf-8")) + except Exception: + # 本地缓存 + return list(self.index_cache.values()) + + results = [] + for pkg_name, pkg_info in index.items(): + if not query or query.lower() in pkg_name.lower() or query.lower() in pkg_info.get("description", "").lower(): + info = PackageInfo() + info.name = pkg_name + info.version = pkg_info.get("version", "") + info.author = pkg_info.get("author", "") + info.description = pkg_info.get("description", "") + info.download_url = pkg_info.get("download_url", "") + info.dependencies = pkg_info.get("dependencies", []) + results.append(info) + self.index_cache[pkg_name] = info + + return results + + def install(self, name: str, version: str = "") -> bool: + """安装包,支持 @{作者/插件名} 格式""" + # 解析输入格式 @{author/plugin} 或直接插件名 + author = "FutureOSS" # 默认作者 + plugin_name = name + + if name.startswith("@{") and "/" in name: + # 解析 @{author/plugin} 格式 + inner = name[2:-1] if name.endswith("}") else name[2:] + parts = inner.split("/", 1) + if len(parts) == 2: + author, plugin_name = parts + + # 搜索获取下载链接 + packages = self.search(plugin_name) + pkg_info = None + for p in packages: + if p.name == plugin_name and p.author == author: + if not version or p.version == version: + pkg_info = p + break + + if not pkg_info or not pkg_info.download_url: + # 尝试从远程仓库直接构建 URL + pkg_info = PackageInfo() + pkg_info.name = plugin_name + pkg_info.author = author + pkg_info.version = version or "1.0.0" + pkg_info.download_url = self.registry + "/store/@{" + author + "/" + plugin_name + "}" + + # 创建安装目录 @{author/plugin_name} + install_dir = PKG_DIR / ("@{" + author + "/" + plugin_name + "}") + install_dir.mkdir(parents=True, exist_ok=True) + + try: + # 下载 manifest.json + manifest_url = f"{pkg_info.download_url}/manifest.json" + with urllib.request.urlopen(manifest_url, timeout=10) as resp: + manifest_data = json.loads(resp.read().decode("utf-8")) + with open(install_dir / "manifest.json", "w", encoding="utf-8") as f: + json.dump(manifest_data, f, ensure_ascii=False, indent=2) + + # 下载 main.py + main_url = f"{pkg_info.download_url}/main.py" + with urllib.request.urlopen(main_url, timeout=10) as resp: + main_data = resp.read().decode("utf-8") + with open(install_dir / "main.py", "w", encoding="utf-8") as f: + f.write(main_data) + + # 更新已安装列表 + full_name = "@{" + author + "/" + plugin_name + self.installed[full_name] = manifest_data + print(f"[pkg] 已安装: {full_name} {manifest_data.get('metadata', {}).get('version', '')}") + return True + except Exception as e: + print(f"[pkg] 安装失败 {name}: {e}") + # 清理失败的安装 + if install_dir.exists(): + shutil.rmtree(install_dir) + return False + + def uninstall(self, name: str) -> bool: + """卸载包""" + install_dir = PKG_DIR / name + if not install_dir.exists(): + print(f"[pkg] 包未安装: {name}") + return False + + try: + shutil.rmtree(install_dir) + del self.installed[name] + print(f"[pkg] 已卸载: {name}") + return True + except Exception as e: + print(f"[pkg] 卸载失败 {name}: {e}") + return False + + def update(self, name: str = "") -> bool: + """更新包""" + if name: + # 更新单个包 + if name not in self.installed: + print(f"[pkg] 包未安装: {name}") + return False + return self.install(name) + else: + # 更新所有已安装的包 + success = True + for pkg_name in list(self.installed.keys()): + if not self.install(pkg_name): + success = False + return success + + def list_installed(self) -> dict[str, Any]: + """列出已安装的包""" + return self.installed + + +class PkgPlugin(Plugin): + """包管理插件""" + + def __init__(self): + self.manager = PackageManager() + + def init(self, deps: dict = None): + """初始化""" + PKG_DIR.mkdir(parents=True, exist_ok=True) + print("[pkg] 包管理器已初始化") + + def start(self): + """启动""" + print(f"[pkg] 包管理器已启动,已安装 {len(self.manager.installed)} 个包") + + def stop(self): + """停止""" + pass + + +# 注册类型 +register_plugin_type("PackageManager", PackageManager) +register_plugin_type("PackageInfo", PackageInfo) + + +def New(): + return PkgPlugin() diff --git a/store/@{FutureOSS}/pkg/manifest.json b/store/@{FutureOSS}/pkg/manifest.json new file mode 100644 index 0000000..1fdf373 --- /dev/null +++ b/store/@{FutureOSS}/pkg/manifest.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "name": "pkg", + "version": "1.0.0", + "author": "FutureOSS", + "description": "包管理 - 插件的搜索、安装、卸载和更新", + "type": "utility" + }, + "config": { + "enabled": true, + "args": { + "registry": "https://gitee.com/starlight-apk/future-oss-pkg/raw/main", + "install_dir": "./data/pkg" + } + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/plugin-bridge/README.md b/store/@{FutureOSS}/plugin-bridge/README.md new file mode 100644 index 0000000..ef274aa --- /dev/null +++ b/store/@{FutureOSS}/plugin-bridge/README.md @@ -0,0 +1,77 @@ +# plugin-bridge 插件桥接器 + +提供插件间的事件共享、广播、桥接和 RPC 服务调用。 + +## 功能 + +- **事件总线**: 插件间共享事件(发布/订阅) +- **广播**: 向多个插件发送消息 +- **桥接**: 将不同插件的事件互相映射 +- **RPC 服务调用**: 插件 A 调用插件 B 的方法并获取返回值 + +## 事件总线(发布/订阅 + 解耦) + +```python +bridge = plugin_mgr.get("plugin-bridge") +bus = bridge.event_bus + +# 订阅事件(发布者和订阅者解耦) +bus.on("http.request", lambda event: print(f"收到请求: {event.payload}")) + +# 发布事件 +bus.emit(BridgeEvent( + type="http.request", + source_plugin="http-api", + payload={"path": "/api/users"} +)) +``` + +## RPC 服务调用 + +```python +# 插件 B 注册服务 +bridge.services.register("plugin-b", "get_user", lambda user_id: {"id": user_id, "name": "test"}) + +# 插件 A 调用插件 B 的服务 +result = bridge.services.call("plugin-b", "get_user", 123) +print(result) # {"id": 123, "name": "test"} +``` + +## 广播 + +```python +broadcast = bridge.broadcast + +# 创建频道 +broadcast.create_channel("system", ["lifecycle", "metrics"]) + +# 广播消息 +broadcast.broadcast("system", {"action": "shutdown"}, "plugin-loader") +``` + +## 桥接 + +```python +bridge_mgr = bridge.bridge + +# 创建桥接:将 http-api 的事件映射到 metrics +bridge_mgr.create_bridge( + name="http-to-metrics", + from_plugin="http-api", + to_plugin="metrics", + event_mapping={ + "http.request": "metrics.http_request", + "http.error": "metrics.http_error", + } +) +``` + +## 事件历史 + +```python +# 查询历史 +history = bus.get_history("http.request") + +# 清空历史 +bus.clear_history() +``` diff --git a/store/@{FutureOSS}/plugin-bridge/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/plugin-bridge/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..a9d82af Binary files /dev/null and b/store/@{FutureOSS}/plugin-bridge/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/plugin-bridge/main.py b/store/@{FutureOSS}/plugin-bridge/main.py new file mode 100644 index 0000000..a884001 --- /dev/null +++ b/store/@{FutureOSS}/plugin-bridge/main.py @@ -0,0 +1,203 @@ +"""插件桥接器 - 共享事件、广播、桥接""" +from typing import Any, Callable, Optional +from dataclasses import dataclass, field + +from oss.plugin.types import Plugin, register_plugin_type + + +@dataclass +class BridgeEvent: + """桥接事件""" + type: str + source_plugin: str + payload: Any = None + context: dict[str, Any] = field(default_factory=dict) + + +class EventBus: + """事件总线""" + + def __init__(self): + self._handlers: dict[str, list[Callable]] = {} + self._history: list[BridgeEvent] = [] + + def emit(self, event: BridgeEvent): + """发布事件""" + self._history.append(event) + handlers = self._handlers.get(event.type, []) + wildcard_handlers = self._handlers.get("*", []) + for handler in handlers + wildcard_handlers: + try: + handler(event) + except Exception: + pass + + def on(self, event_type: str, handler: Callable): + """订阅事件""" + if event_type not in self._handlers: + self._handlers[event_type] = [] + self._handlers[event_type].append(handler) + + def off(self, event_type: str, handler: Callable): + """取消订阅""" + if event_type in self._handlers: + try: + self._handlers[event_type].remove(handler) + except ValueError: + pass + + def once(self, event_type: str, handler: Callable): + """仅触发一次""" + def wrapper(event): + self.off(event_type, wrapper) + handler(event) + self.on(event_type, wrapper) + + def get_history(self, event_type: str = None) -> list[BridgeEvent]: + """获取事件历史""" + if event_type: + return [e for e in self._history if e.type == event_type] + return self._history.copy() + + def clear_history(self): + """清空事件历史""" + self._history.clear() + + +class BroadcastManager: + """广播管理器""" + + def __init__(self, event_bus: EventBus): + self.event_bus = event_bus + self._channels: dict[str, list[str]] = {} + + def create_channel(self, name: str, plugins: list[str]): + """创建广播频道""" + self._channels[name] = plugins + + def broadcast(self, channel: str, payload: Any, source_plugin: str = ""): + """广播到指定频道""" + if channel not in self._channels: + return + event = BridgeEvent( + type=f"broadcast.{channel}", + source_plugin=source_plugin, + payload=payload + ) + self.event_bus.emit(event) + + def get_channels(self) -> dict[str, list[str]]: + """获取所有频道""" + return self._channels.copy() + + +class ServiceRegistry: + """服务注册表(RPC)""" + + def __init__(self): + self._services: dict[str, dict[str, Callable]] = {} + + def register(self, plugin_name: str, service_name: str, handler: Callable): + """注册服务""" + if plugin_name not in self._services: + self._services[plugin_name] = {} + self._services[plugin_name][service_name] = handler + + def unregister(self, plugin_name: str, service_name: str = None): + """注销服务""" + if plugin_name in self._services: + if service_name: + self._services[plugin_name].pop(service_name, None) + else: + del self._services[plugin_name] + + def call(self, plugin_name: str, service_name: str, *args, **kwargs) -> Any: + """远程调用""" + if plugin_name not in self._services: + raise RuntimeError(f"插件 '{plugin_name}' 未注册服务") + if service_name not in self._services[plugin_name]: + raise RuntimeError(f"插件 '{plugin_name}' 未注册服务 '{service_name}'") + return self._services[plugin_name][service_name](*args, **kwargs) + + def list_services(self, plugin_name: str = None) -> dict[str, dict[str, Callable]]: + """列出服务""" + if plugin_name: + return self._services.get(plugin_name, {}).copy() + return {k: v.copy() for k, v in self._services.items()} + + +class BridgeManager: + """桥接管理器""" + + def __init__(self, event_bus: EventBus): + self.event_bus = event_bus + self._bridges: dict[str, dict[str, Any]] = {} + + def create_bridge(self, name: str, from_plugin: str, to_plugin: str, event_mapping: dict[str, str]): + """创建桥接:将 from_plugin 的事件映射到 to_plugin""" + self._bridges[name] = { + "from": from_plugin, + "to": to_plugin, + "mapping": event_mapping, + } + # 注册桥接处理器 + for src_event, dst_event in event_mapping.items(): + def handler(event, dst_event=dst_event): + bridged = BridgeEvent( + type=dst_event, + source_plugin=event.source_plugin, + payload=event.payload, + context={**event.context, "_bridged_from": event.type} + ) + self.event_bus.emit(bridged) + self.event_bus.on(src_event, handler) + + def remove_bridge(self, name: str): + """移除桥接""" + if name in self._bridges: + del self._bridges[name] + + def get_bridges(self) -> dict[str, dict[str, Any]]: + """获取所有桥接""" + return self._bridges.copy() + + +class PluginBridgePlugin(Plugin): + """插件桥接器插件""" + + def __init__(self): + self.event_bus = EventBus() + self.broadcast = None + self.bridge = None + self.services = ServiceRegistry() + self.storage = None # 共享存储接口 + + def init(self, deps: dict = None): + """初始化""" + self.broadcast = BroadcastManager(self.event_bus) + self.bridge = BridgeManager(self.event_bus) + + def start(self): + """启动""" + print("[plugin-bridge] 事件总线、广播、桥接、RPC、共享存储已启动") + + def stop(self): + """停止""" + self.event_bus.clear_history() + + def set_plugin_storage(self, storage_plugin): + """设置存储插件引用""" + if storage_plugin: + self.storage = storage_plugin.get_shared() + + +# 注册类型 +register_plugin_type("BridgeEvent", BridgeEvent) +register_plugin_type("EventBus", EventBus) +register_plugin_type("BroadcastManager", BroadcastManager) +register_plugin_type("BridgeManager", BridgeManager) +register_plugin_type("ServiceRegistry", ServiceRegistry) + + +def New(): + return PluginBridgePlugin() diff --git a/store/@{FutureOSS}/plugin-bridge/manifest.json b/store/@{FutureOSS}/plugin-bridge/manifest.json new file mode 100644 index 0000000..401e89a --- /dev/null +++ b/store/@{FutureOSS}/plugin-bridge/manifest.json @@ -0,0 +1,15 @@ +{ + "metadata": { + "name": "plugin-bridge", + "version": "1.0.0", + "author": "FutureOSS", + "description": "插件桥接器 - 共享事件、广播、桥接", + "type": "core" + }, + "config": { + "enabled": true, + "args": {} + }, + "dependencies": ["plugin-storage"], + "permissions": ["plugin-storage"] +} diff --git a/store/@{FutureOSS}/plugin-loader/README.md b/store/@{FutureOSS}/plugin-loader/README.md new file mode 100644 index 0000000..652aa52 --- /dev/null +++ b/store/@{FutureOSS}/plugin-loader/README.md @@ -0,0 +1,16 @@ +# plugin-loader 插件加载器 + +核心插件,负责扫描、加载和管理所有其他插件。 + +## 功能 + +- 自动扫描 `store/` 和 `./data/pkg/` 目录 +- 动态加载 `main.py` 并调用 `New()` 获取实例 +- 解析 `manifest.json` 获取插件元数据 +- 自动扫描插件能力(AST 分析) +- 按依赖关系排序加载顺序 +- 关联能力提供者与消费者 + +## 使用 + +无需手动使用,框架启动时自动加载。 diff --git a/store/@{FutureOSS}/plugin-loader/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/plugin-loader/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..8268092 Binary files /dev/null and b/store/@{FutureOSS}/plugin-loader/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/plugin-loader/main.py b/store/@{FutureOSS}/plugin-loader/main.py new file mode 100644 index 0000000..ba74364 --- /dev/null +++ b/store/@{FutureOSS}/plugin-loader/main.py @@ -0,0 +1,609 @@ +"""插件加载器插件 - 支持能力扫描和扩展""" +import sys +import json +import importlib.util +from pathlib import Path +from typing import Any, Optional + +from oss.plugin.types import Plugin, register_plugin_type +from oss.plugin.capabilities import scan_capabilities + + +class PluginInfo: + """插件信息""" + def __init__(self): + self.name: str = "" + self.version: str = "" + self.author: str = "" + self.description: str = "" + self.readme: str = "" + self.config: dict[str, Any] = {} + self.extensions: dict[str, Any] = {} + self.lifecycle: Any = None + self.capabilities: set[str] = set() + self.dependencies: list[str] = [] + + +class PermissionError(Exception): + """权限错误""" + pass + + +class PluginProxy: + """插件代理 - 防止越级访问""" + + def __init__(self, plugin_name: str, plugin_instance: Any, allowed_plugins: list[str], all_plugins: dict[str, dict[str, Any]]): + self._plugin_name = plugin_name + self._plugin_instance = plugin_instance + self._allowed_plugins = set(allowed_plugins) + self._all_plugins = all_plugins + + def get_plugin(self, name: str) -> Any: + """获取其他插件实例(带权限检查)""" + if name not in self._allowed_plugins and "*" not in self._allowed_plugins: + raise PermissionError(f"插件 '{self._plugin_name}' 无权访问插件 '{name}'") + if name not in self._all_plugins: + return None + return self._all_plugins[name]["instance"] + + def list_plugins(self) -> list[str]: + """列出有权限访问的插件""" + if "*" in self._allowed_plugins: + return list(self._all_plugins.keys()) + return [name for name in self._allowed_plugins if name in self._all_plugins] + + def get_capability(self, capability: str) -> Any: + """获取能力(带权限检查)""" + # 能力访问不需要额外权限,能力注册表会自动处理 + return None + + def __getattr__(self, name: str): + """代理其他属性到插件实例""" + return getattr(self._plugin_instance, name) + + +class CapabilityRegistry: + """能力注册表""" + + def __init__(self, permission_check: bool = True): + self.providers: dict[str, dict[str, Any]] = {} + self.consumers: dict[str, list[str]] = {} + self.permission_check = permission_check + + def register_provider(self, capability: str, plugin_name: str, instance: Any): + """注册能力提供者""" + self.providers[capability] = { + "plugin": plugin_name, + "instance": instance, + } + if capability not in self.consumers: + self.consumers[capability] = [] + + def register_consumer(self, capability: str, plugin_name: str): + """注册能力消费者""" + if capability not in self.consumers: + self.consumers[capability] = [] + if plugin_name not in self.consumers[capability]: + self.consumers[capability].append(plugin_name) + + def get_provider(self, capability: str, requester: str = "", allowed_plugins: list[str] = None) -> Optional[Any]: + """获取能力提供者实例(带权限检查)""" + if capability not in self.providers: + return None + + if self.permission_check and allowed_plugins is not None: + provider_name = self.providers[capability]["plugin"] + if provider_name != requester and provider_name not in allowed_plugins and "*" not in allowed_plugins: + raise PermissionError(f"插件 '{requester}' 无权使用能力 '{capability}'") + + return self.providers[capability]["instance"] + + def has_capability(self, capability: str) -> bool: + """检查是否有某个能力""" + return capability in self.providers + + def get_consumers(self, capability: str) -> list[str]: + """获取能力消费者列表""" + return self.consumers.get(capability, []) + + +class PluginManager: + """插件管理器""" + + def __init__(self, permission_check: bool = True): + self.plugins: dict[str, dict[str, Any]] = {} + self.lifecycle_plugin: Optional[Any] = None + self._dependency_plugin: Optional[Any] = None # dependency 插件引用 + self.capability_registry = CapabilityRegistry(permission_check=permission_check) + self.permission_check = permission_check + + def set_lifecycle(self, lifecycle_plugin: Any): + """设置生命周期插件""" + self.lifecycle_plugin = lifecycle_plugin + + def _load_manifest(self, plugin_dir: Path) -> dict[str, Any]: + """加载 manifest.json""" + manifest_file = plugin_dir / "manifest.json" + if not manifest_file.exists(): + return {} + with open(manifest_file, "r", encoding="utf-8") as f: + return json.load(f) + + def _load_readme(self, plugin_dir: Path) -> str: + """加载 README.md""" + readme_file = plugin_dir / "README.md" + if not readme_file.exists(): + return "" + with open(readme_file, "r", encoding="utf-8") as f: + return f.read() + + def _load_config(self, plugin_dir: Path) -> dict[str, Any]: + """加载 Python 配置文件(带安全措施)""" + config_file = plugin_dir / "config.py" + if not config_file.exists(): + return {} + + safe_globals = { + "__builtins__": { + "True": True, + "False": False, + "None": None, + "dict": dict, + "list": list, + "str": str, + "int": int, + "float": float, + "bool": bool, + } + } + local_vars = {} + + with open(config_file, "r", encoding="utf-8") as f: + code = compile(f.read(), str(config_file), "exec") + exec(code, safe_globals, local_vars) + + return { + k: v for k, v in local_vars.items() + if not k.startswith("_") and not callable(v) + } + + def _load_extensions(self, plugin_dir: Path) -> dict[str, Any]: + """加载扩展语法(Python 文件)""" + ext_file = plugin_dir / "extensions.py" + if not ext_file.exists(): + return {} + + safe_globals = { + "__builtins__": { + "True": True, + "False": False, + "None": None, + "dict": dict, + "list": list, + "str": str, + "int": int, + "float": float, + "bool": bool, + } + } + local_vars = {} + + with open(ext_file, "r", encoding="utf-8") as f: + code = compile(f.read(), str(ext_file), "exec") + exec(code, safe_globals, local_vars) + + return { + k: v for k, v in local_vars.items() + if not k.startswith("_") and not callable(v) + } + + def load(self, plugin_dir: Path, use_sandbox: bool = True) -> Optional[Any]: + """加载单个插件""" + main_file = plugin_dir / "main.py" + if not main_file.exists(): + return None + + manifest = self._load_manifest(plugin_dir) + readme = self._load_readme(plugin_dir) + config = self._load_config(plugin_dir) + extensions = self._load_extensions(plugin_dir) + + # 自动扫描能力 + capabilities = scan_capabilities(plugin_dir) + + plugin_name = plugin_dir.name + + # 清理插件名(去掉 } 后缀) + plugin_name = plugin_dir.name.rstrip("}") + + # 解析权限 + permissions = manifest.get("permissions", []) + + # 沙箱加载 + if use_sandbox: + from oss.plugin.loader import PluginLoader as FrameworkLoader + framework_loader = FrameworkLoader(enable_sandbox=True) + result = framework_loader.load_sandbox_plugin(plugin_dir) + if not result: + return None + module = result["module"] + instance = result["instance"] + else: + spec = importlib.util.spec_from_file_location( + f"plugin.{plugin_name}", str(main_file) + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + if not hasattr(module, "New"): + return None + + instance = module.New() + + # 创建代理包装器 + if self.permission_check and permissions: + instance = PluginProxy(plugin_name, instance, permissions, self.plugins) + + info = PluginInfo() + meta = manifest.get("metadata", {}) + info.name = meta.get("name", plugin_name) + info.version = meta.get("version", "") + info.author = meta.get("author", "") + info.description = meta.get("description", "") + info.readme = readme + info.config = manifest.get("config", {}).get("args", config) + info.extensions = extensions + info.capabilities = capabilities + info.dependencies = manifest.get("dependencies", []) + + # 注册能力 + for cap in capabilities: + self.capability_registry.register_provider(cap, plugin_name, instance) + + # 创建生命周期 + if self.lifecycle_plugin and plugin_name != "lifecycle": + lc = self.lifecycle_plugin.create(plugin_name) + info.lifecycle = lc + + self.plugins[plugin_name] = { + "instance": instance, + "module": module, + "info": info, + "permissions": permissions, + } + return instance + + def load_all(self, store_dir: str = "store"): + """加载 store 和 data/pkg 下所有插件(跳过自己)""" + # 检查是否有任何插件存在 + has_plugins = self._check_any_plugins(store_dir) + + if not has_plugins: + print("[plugin-loader] 未检测到任何插件,自动引导安装...") + self._bootstrap_installation() + + # 可选:加载 lifecycle + lifecycle_plugin = None + lifecycle_dir = Path(store_dir) / "@{FutureOSS}" / "lifecycle" + if lifecycle_dir.exists() and (lifecycle_dir / "main.py").exists(): + try: + instance = self.load(lifecycle_dir) + if instance: + lifecycle_plugin = instance + self.plugins.pop("lifecycle", None) + except Exception: + pass + + # 可选:加载 dependency + dependency_plugin = None + dependency_dir = Path(store_dir) / "@{FutureOSS}" / "dependency" + if dependency_dir.exists() and (dependency_dir / "main.py").exists(): + try: + instance = self.load(dependency_dir) + if instance: + dependency_plugin = instance + self._dependency_plugin = instance # 保存引用供拓扑排序使用 + self.plugins.pop("dependency", None) + except Exception: + pass + + # 加载 lifecycle + if lifecycle_plugin: + self.set_lifecycle(lifecycle_plugin) + + # 加载其他插件 + self._load_plugins_from_dir(Path(store_dir)) + self._load_plugins_from_dir(Path("./data/pkg")) + + # 可选:按依赖排序 + if dependency_plugin: + self._sort_by_dependencies(dependency_plugin) + + def _load_plugins_from_dir(self, store_dir: Path): + """从指定目录加载插件""" + if not store_dir.exists(): + return + + # 第一遍:加载所有插件 + for author_dir in store_dir.iterdir(): + if author_dir.is_dir(): + for plugin_dir in author_dir.iterdir(): + if plugin_dir.is_dir() and plugin_dir.name not in ("plugin-loader", "lifecycle", "dependency"): + if (plugin_dir / "main.py").exists(): + self.load(plugin_dir) + + # 第二遍:关联能力 + self._link_capabilities() + + def _check_any_plugins(self, store_dir: str) -> bool: + """检查是否存在任何插件""" + store = Path(store_dir) + if store.exists(): + for author_dir in store.iterdir(): + if author_dir.is_dir(): + for plugin_dir in author_dir.iterdir(): + if plugin_dir.is_dir() and (plugin_dir / "main.py").exists(): + return True + + pkg_dir = Path("./data/pkg") + if pkg_dir.exists(): + for d in pkg_dir.iterdir(): + if d.is_dir() and (d / "main.py").exists(): + return True + + return False + + def _bootstrap_installation(self): + """引导安装 FutureOSS 官方插件""" + # 加载 pkg 插件 + pkg_dir = Path("store/@{FutureOSS}/pkg") + if pkg_dir.exists() and (pkg_dir / "main.py").exists(): + try: + pkg_instance = self.load(pkg_dir, use_sandbox=False) + if pkg_instance: + pkg_mgr = pkg_instance.manager + + print("[plugin-loader] 正在搜索可用插件...") + results = pkg_mgr.search() + if not results: + print("[plugin-loader] 未找到远程插件") + return + + print(f"[plugin-loader] 发现 {len(results)} 个插件,开始安装...") + installed_count = 0 + for pkg_info in results: + print(f"[plugin-loader] 安装: {pkg_info.name}") + if pkg_mgr.install(pkg_info.name): + installed_count += 1 + + if installed_count > 0: + print(f"[plugin-loader] 已安装 {installed_count} 个插件,重新扫描加载...") + # pkg 保留,重新加载其他插件 + except Exception as e: + print(f"[plugin-loader] 引导安装失败: {e}") + else: + print("[plugin-loader] pkg 插件不存在,跳过引导安装") + + def _sort_by_dependencies(self, dep_plugin): + """按依赖关系排序""" + if not dep_plugin: + return + + # 添加所有插件的依赖 + for name, info in self.plugins.items(): + deps = info["info"].dependencies + dep_plugin.add_plugin(name, deps) + + try: + order = dep_plugin.resolve() + # 重新排序 plugins + sorted_plugins = {} + for name in order: + if name in self.plugins: + sorted_plugins[name] = self.plugins[name] + + # 检查是否所有插件都在排序结果中 + missing = set(self.plugins.keys()) - set(sorted_plugins.keys()) + for name in missing: + sorted_plugins[name] = self.plugins[name] + + self.plugins = sorted_plugins + except Exception as e: + print(f"[plugin-loader] 依赖解析失败: {e}") + + def _link_capabilities(self): + """关联能力:带权限检查""" + for plugin_name, info in self.plugins.items(): + caps = info["info"].capabilities + allowed = info.get("permissions", []) + + for cap in caps: + # 如果这个插件是某个能力的提供者 + if self.capability_registry.has_capability(cap): + # 找到所有需要这个能力的消费者 + consumers = self.capability_registry.get_consumers(cap) + for consumer_name in consumers: + if consumer_name in self.plugins: + consumer_info = self.plugins[consumer_name]["info"] + consumer_allowed = self.plugins[consumer_name].get("permissions", []) + # 权限检查 + try: + provider = self.capability_registry.get_provider( + cap, + requester=consumer_name, + allowed_plugins=consumer_allowed + ) + if provider and hasattr(consumer_info, "extensions"): + consumer_info.extensions[f"_{cap}_provider"] = provider + except PermissionError as e: + print(f"[plugin-loader] 权限拒绝: {e}") + + def start_all(self): + """启动所有插件(假设已初始化)""" + # 注入依赖实例 + self._inject_dependencies() + + # 启动所有插件 + for name, info in self.plugins.items(): + try: + info["instance"].start() + except Exception as e: + print(f"[plugin-loader] 启动失败 {name}: {e}") + + def init_and_start_all(self): + """初始化并启动所有插件 + + 正确顺序: + 1. 注入依赖实例 + 2. 按拓扑顺序 init() 所有插件 + 3. 按拓扑顺序 start() 所有插件 + """ + print(f"[plugin-loader] init_and_start_all 被调用,plugins={len(self.plugins)}") + + # 1. 注入依赖实例 + self._inject_dependencies() + + # 2. 获取拓扑排序 + ordered_plugins = self._get_ordered_plugins() + print(f"[plugin-loader] 插件启动顺序: {' -> '.join(ordered_plugins)}") + + # 3. 初始化所有插件(跳过 plugin-loader 自己) + print("[plugin-loader] 开始初始化所有插件...") + for name in ordered_plugins: + if "plugin-loader" in name: + continue + info = self.plugins[name] + try: + print(f"[plugin-loader] 初始化: {name}") + info["instance"].init() + except Exception as e: + print(f"[plugin-loader] 初始化失败 {name}: {e}") + + # 4. 启动所有插件(跳过 plugin-loader 自己) + print("[plugin-loader] 开始启动所有插件...") + for name in ordered_plugins: + if "plugin-loader" in name: + continue + info = self.plugins[name] + try: + print(f"[plugin-loader] 启动: {name}") + info["instance"].start() + except Exception as e: + print(f"[plugin-loader] 启动失败 {name}: {e}") + + def _get_ordered_plugins(self) -> list[str]: + """获取按依赖排序的插件列表""" + # 如果没有 dependency 插件,直接返回原始顺序 + if not hasattr(self, '_dependency_plugin') or not self._dependency_plugin: + return list(self.plugins.keys()) + + try: + # 使用 dependency 插件解析 + order = self._dependency_plugin.resolve() + # 过滤出实际存在的插件 + return [name for name in order if name in self.plugins] + except Exception as e: + print(f"[plugin-loader] 依赖解析失败,使用原始顺序: {e}") + return list(self.plugins.keys()) + + def _inject_dependencies(self): + """注入插件依赖实例""" + print(f"[plugin-loader] 开始注入依赖,共 {len(self.plugins)} 个插件") + + # 构建名称映射(处理 } 后缀问题) + name_map = {} + for name in self.plugins: + clean = name.rstrip("}") + name_map[clean] = name + name_map[clean + "}"] = name + + for name, info in self.plugins.items(): + instance = info["instance"] + info_obj = info.get("info") + if not info_obj: + continue + deps = info_obj.dependencies + if not deps: + continue + + print(f"[plugin-loader] {name} 依赖: {deps}") + for dep_name in deps: + # 使用名称映射查找 + actual_dep = name_map.get(dep_name) or name_map.get(dep_name + "}") + if actual_dep and actual_dep in self.plugins: + dep_instance = self.plugins[actual_dep]["instance"] + setter_name = f"set_{dep_name.replace('-', '_')}" + print(f"[plugin-loader] 尝试注入: {name} <- {actual_dep} ({setter_name})") + if hasattr(instance, setter_name): + try: + getattr(instance, setter_name)(dep_instance) + print(f"[plugin-loader] 注入成功: {name} <- {actual_dep}") + except Exception as e: + print(f"[plugin-loader] 注入依赖失败 {name}.{setter_name}: {e}") + else: + print(f"[plugin-loader] 警告: {name} 没有 {setter_name} 方法") + + def stop_all(self): + """停止所有插件""" + for name, info in reversed(list(self.plugins.items())): + try: + info["instance"].stop() + except Exception: + pass + if self.lifecycle_plugin: + self.lifecycle_plugin.stop_all() + + def get_info(self, name: str) -> Optional[PluginInfo]: + """获取插件信息""" + if name in self.plugins: + return self.plugins[name]["info"] + return None + + def has_capability(self, capability: str) -> bool: + """检查系统是否有某个能力""" + return self.capability_registry.has_capability(capability) + + def get_capability_provider(self, capability: str) -> Optional[Any]: + """获取能力提供者""" + return self.capability_registry.get_provider(capability) + + +class PluginLoaderPlugin(Plugin): + """插件加载器插件""" + + def __init__(self): + self.manager = PluginManager() + self._loaded = False + self._started = False + + def init(self, deps: dict = None): + """加载所有插件""" + if self._loaded: + return + self._loaded = True + print("[plugin-loader] 开始加载插件...") + self.manager.load_all() + + def start(self): + """启动所有插件""" + if self._started: + return + self._started = True + print("[plugin-loader] 启动插件...") + self.manager.init_and_start_all() + + def stop(self): + """停止所有插件""" + print("[plugin-loader] 停止插件...") + self.manager.stop_all() + + +# 注册类型 +register_plugin_type("PluginManager", PluginManager) +register_plugin_type("PluginInfo", PluginInfo) +register_plugin_type("CapabilityRegistry", CapabilityRegistry) + + +def New(): + return PluginLoaderPlugin() diff --git a/store/@{FutureOSS}/plugin-loader/manifest.json b/store/@{FutureOSS}/plugin-loader/manifest.json new file mode 100644 index 0000000..70fff14 --- /dev/null +++ b/store/@{FutureOSS}/plugin-loader/manifest.json @@ -0,0 +1,19 @@ +{ + "metadata": { + "name": "plugin-loader", + "version": "1.0.0", + "author": "FutureOSS", + "description": "插件加载器 - 负责扫描、加载和管理所有插件", + "type": "core" + }, + "config": { + "enabled": true, + "args": { + "scan_dirs": ["store", "./data/pkg"], + "sandbox_enabled": true, + "permission_check": true + } + }, + "dependencies": [], + "permissions": ["*"] +} diff --git a/store/@{FutureOSS}/plugin-storage/README.md b/store/@{FutureOSS}/plugin-storage/README.md new file mode 100644 index 0000000..2543ab9 --- /dev/null +++ b/store/@{FutureOSS}/plugin-storage/README.md @@ -0,0 +1,72 @@ +# plugin-storage 插件存储 + +为所有插件提供隔离的键值存储服务。 + +## 功能 + +- **隔离存储**:每个插件有独立的命名空间 +- **持久化**:数据自动保存到 JSON 文件 +- **线程安全**:支持并发访问 +- **共享访问**:通过 plugin-bridge 可跨插件访问 + +## 基本使用 + +```python +storage_plugin = plugin_mgr.get("plugin-storage") + +# 获取插件的隔离存储 +storage = storage_plugin.get_storage("my-plugin") + +# 设置值 +storage.set("key", "value") +storage.set("config", {"theme": "dark", "lang": "zh"}) + +# 获取值 +value = storage.get("key") +config = storage.get("config", default={}) + +# 检查键 +if storage.has("key"): + print("存在") + +# 删除 +storage.delete("key") + +# 批量设置 +storage.set_many({"a": 1, "b": 2, "c": 3}) + +# 获取所有数据 +all_data = storage.get_all() + +# 清空 +storage.clear() +``` + +## 通过 plugin-bridge 访问 + +```python +bridge = plugin_mgr.get("plugin-bridge") +shared_storage = bridge.storage # 假设 bridge 集成了 storage + +# 获取其他插件的存储(需要权限) +other_storage = shared_storage.get_plugin_storage("other-plugin") +data = other_storage.get("some_key") +``` + +## 存储位置 + +``` +./data/storage/ +├── plugin-a/ +│ └── data.json +├── plugin-b/ +│ └── data.json +└── ... +``` + +## 元信息 + +```python +meta = storage.get_meta() +# {"plugin": "my-plugin", "keys": 5, "path": "./data/storage/my-plugin"} +``` diff --git a/store/@{FutureOSS}/plugin-storage/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/plugin-storage/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..09992f1 Binary files /dev/null and b/store/@{FutureOSS}/plugin-storage/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/plugin-storage/main.py b/store/@{FutureOSS}/plugin-storage/main.py new file mode 100644 index 0000000..6953bfd --- /dev/null +++ b/store/@{FutureOSS}/plugin-storage/main.py @@ -0,0 +1,350 @@ +"""插件存储插件入口 - 统一文件读写服务""" +import json +import threading +import mimetypes +import shutil +from pathlib import Path +from typing import Any, Optional, BinaryIO +from datetime import datetime + +from oss.plugin.types import Plugin, register_plugin_type, Response + + +class PluginStorage: + """插件隔离存储 - 每个插件拥有独立的 data// 目录""" + + def __init__(self, plugin_name: str, data_dir: str = "./data"): + self.plugin_name = plugin_name + self.data_dir = Path(data_dir) / plugin_name + self.data_dir.mkdir(parents=True, exist_ok=True) + self._data: dict[str, Any] = {} + self._lock = threading.Lock() + self._load() + + # ========== JSON 键值存储 ========== + + def _load(self): + """加载 JSON 存储数据""" + data_file = self.data_dir / "data.json" + if data_file.exists(): + try: + with open(data_file, "r", encoding="utf-8") as f: + content = f.read().strip() + if content: + self._data = json.loads(content) + else: + self._data = {} + except (json.JSONDecodeError, IOError) as e: + print(f"[plugin-storage] 加载数据失败 {self.plugin_name}: {e}") + self._data = {} + + def _save(self): + """保存 JSON 存储数据""" + data_file = self.data_dir / "data.json" + with open(data_file, "w", encoding="utf-8") as f: + json.dump(self._data, f, ensure_ascii=False, indent=2) + + def get(self, key: str, default: Any = None) -> Any: + """获取 JSON 值""" + with self._lock: + return self._data.get(key, default) + + def set(self, key: str, value: Any): + """设置 JSON 值""" + with self._lock: + self._data[key] = value + self._save() + + def delete(self, key: str) -> bool: + """删除 JSON 键""" + with self._lock: + if key in self._data: + del self._data[key] + self._save() + return True + return False + + def has(self, key: str) -> bool: + """检查 JSON 键是否存在""" + with self._lock: + return key in self._data + + def keys(self) -> list[str]: + """获取所有 JSON 键""" + with self._lock: + return list(self._data.keys()) + + def clear(self): + """清空 JSON 存储""" + with self._lock: + self._data.clear() + self._save() + + def size(self) -> int: + """获取 JSON 存储大小(键数量)""" + with self._lock: + return len(self._data) + + def get_all(self) -> dict[str, Any]: + """获取所有 JSON 数据""" + with self._lock: + return self._data.copy() + + def set_many(self, data: dict[str, Any]): + """批量设置 JSON""" + with self._lock: + self._data.update(data) + self._save() + + def get_meta(self) -> dict[str, Any]: + """获取存储元信息""" + return { + "plugin": self.plugin_name, + "keys": self.size(), + "path": str(self.data_dir), + } + + # ========== 文件级别操作 ========== + + def read_file(self, path: str, mode: str = "r") -> Optional[str | bytes]: + """读取插件目录内的文件 + + Args: + path: 相对于插件数据目录的路径,如 "index.html" 或 "templates/home.html" + mode: "r" (文本) 或 "rb" (二进制) + + Returns: + 文件内容,文件不存在时返回 None + """ + try: + file_path = self._resolve_path(path) + if not file_path.exists() or not file_path.is_file(): + return None + with open(file_path, mode, encoding="utf-8" if mode == "r" else None) as f: + return f.read() + except Exception as e: + print(f"[plugin-storage] 读取文件失败 {self.plugin_name}/{path}: {e}") + return None + + def write_file(self, path: str, content: str | bytes): + """写入文件到插件目录 + + Args: + path: 相对于插件数据目录的路径 + content: 文件内容(字符串或字节) + """ + try: + file_path = self._resolve_path(path) + file_path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + with open(file_path, "wb") as f: + f.write(content) + else: + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + except Exception as e: + print(f"[plugin-storage] 写入文件失败 {self.plugin_name}/{path}: {e}") + + def delete_file(self, path: str) -> bool: + """删除插件目录内的文件""" + try: + file_path = self._resolve_path(path) + if file_path.exists(): + file_path.unlink() + return True + return False + except Exception as e: + print(f"[plugin-storage] 删除文件失败 {self.plugin_name}/{path}: {e}") + return False + + def list_files(self, prefix: str = "") -> list[str]: + """列出插件目录内的文件 + + Args: + prefix: 子目录前缀,如 "templates/" 或 ""(全部) + + Returns: + 相对路径列表 + """ + try: + search_dir = self._resolve_path(prefix) if prefix else self.data_dir + if not search_dir.exists(): + return [] + files = [] + for f in search_dir.rglob("*"): + if f.is_file(): + files.append(str(f.relative_to(self.data_dir))) + return sorted(files) + except Exception: + return [] + + def file_exists(self, path: str) -> bool: + """检查文件是否存在""" + try: + file_path = self._resolve_path(path) + return file_path.exists() and file_path.is_file() + except Exception: + return False + + def serve_file(self, path: str) -> Response: + """提供文件服务(返回 HTTP Response) + + 用于插件向外部提供静态文件。 + 自动检测 MIME 类型,支持文本和二进制文件。 + + Args: + path: 相对于插件数据目录的路径 + + Returns: + Response 对象(200 成功 / 404 不存在 / 403 安全拦截) + """ + try: + file_path = self._resolve_path(path) + + # 安全检查:防止目录遍历 + try: + file_path.resolve().relative_to(self.data_dir.resolve()) + except ValueError: + return Response(status=403, body="Forbidden: path traversal detected") + + if not file_path.exists() or not file_path.is_file(): + return Response(status=404, body=f"File not found: {path}") + + # 检测 MIME 类型 + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "application/octet-stream" + + # 读取文件内容 + if content_type.startswith("text/") or content_type in ( + "application/json", "application/javascript", "application/xml", + "text/css", "text/html", "image/svg+xml" + ): + content = file_path.read_text(encoding="utf-8") + else: + content = file_path.read_bytes() + + return Response( + status=200, + headers={ + "Content-Type": content_type, + "Cache-Control": "public, max-age=3600", + }, + body=content, + ) + except Exception as e: + return Response(status=500, body=f"Error serving file: {e}") + + def _resolve_path(self, path: str) -> Path: + """解析相对于插件数据目录的安全路径""" + return (self.data_dir / path).resolve() + + def get_data_dir(self) -> Path: + """获取插件数据目录绝对路径""" + return self.data_dir.resolve() + + +class SharedStorage: + """共享存储(供 plugin-bridge 使用)""" + + def __init__(self, storage_manager, shared_dir: Path = None): + self._manager = storage_manager + self._shared_dir = shared_dir or Path("./data/DCIM") + self._shared_dir.mkdir(parents=True, exist_ok=True) + + def get_plugin_storage(self, plugin_name: str) -> PluginStorage: + """获取指定插件的存储空间""" + return self._manager.get_storage(plugin_name) + + def get_shared(self, key: str, default: Any = None) -> Any: + """获取共享存储 (DCIM)""" + shared_file = self._shared_dir / f"{key}.json" + if shared_file.exists(): + with open(shared_file, "r", encoding="utf-8") as f: + return json.load(f) + return default + + def set_shared(self, key: str, value: Any): + """设置共享存储 (DCIM)""" + shared_file = self._shared_dir / f"{key}.json" + with open(shared_file, "w", encoding="utf-8") as f: + json.dump(value, f, ensure_ascii=False, indent=2) + + def list_storages(self) -> list[str]: + """列出所有有存储的插件""" + return self._manager.list_storages() + + +class PluginStoragePlugin(Plugin): + """插件存储插件 - 所有插件的唯一文件读写入口""" + + def __init__(self): + self.storages: dict[str, PluginStorage] = {} + self.shared = None + self.config = {} + self.data_root = Path("./data") + + def init(self, deps: dict = None): + """初始化 - 读取 config.json 配置""" + self._load_config() + + def start(self): + """启动""" + print(f"[plugin-storage] 插件存储服务已启动 (root={self.data_root})") + + def stop(self): + """停止""" + pass + + def _load_config(self): + """读取 config.json 配置""" + config_path = Path("./data/plugin-storage/config.json") + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + self.config = json.load(f) + self.data_root = Path(self.config.get("data_root", "./data")) + shared_dir_name = self.config.get("shared_dir", "DCIM") + shared_dir = self.data_root / shared_dir_name + else: + print("[plugin-storage] config.json 不存在,使用默认配置") + self.config = {"data_root": "./data", "shared_dir": "DCIM"} + self.data_root = Path("./data") + shared_dir = self.data_root / "DCIM" + + self.shared = SharedStorage(self, shared_dir=shared_dir) + + def get_storage(self, plugin_name: str) -> PluginStorage: + """获取插件的隔离存储空间(唯一入口)""" + if plugin_name not in self.storages: + self.storages[plugin_name] = PluginStorage( + plugin_name, + data_dir=str(self.data_root) + ) + return self.storages[plugin_name] + + def remove_storage(self, plugin_name: str) -> bool: + """删除插件的存储空间""" + if plugin_name in self.storages: + del self.storages[plugin_name] + data_dir = PluginStorage(plugin_name).data_dir + if data_dir.exists(): + shutil.rmtree(data_dir) + return True + return False + + def list_storages(self) -> list[str]: + """列出所有有存储的插件""" + return list(self.storages.keys()) + + def get_shared(self) -> SharedStorage: + """获取共享存储接口""" + return self.shared + + +# 注册类型 +register_plugin_type("PluginStorage", PluginStorage) +register_plugin_type("SharedStorage", SharedStorage) + + +def New(): + return PluginStoragePlugin() diff --git a/store/@{FutureOSS}/plugin-storage/manifest.json b/store/@{FutureOSS}/plugin-storage/manifest.json new file mode 100644 index 0000000..e43fdd8 --- /dev/null +++ b/store/@{FutureOSS}/plugin-storage/manifest.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "name": "plugin-storage", + "version": "1.0.0", + "author": "FutureOSS", + "description": "插件存储 - 为所有插件提供隔离的键值存储服务", + "type": "utility" + }, + "config": { + "enabled": true, + "args": { + "data_dir": "./data/storage" + } + }, + "dependencies": [], + "permissions": ["*"] +} diff --git a/store/@{FutureOSS}/ws-api/README.md b/store/@{FutureOSS}/ws-api/README.md new file mode 100644 index 0000000..a136378 --- /dev/null +++ b/store/@{FutureOSS}/ws-api/README.md @@ -0,0 +1,50 @@ +# ws-api WebSocket API + +提供 WebSocket 实时双向通信服务。 + +## 功能 + +- WebSocket 服务器 +- 路由匹配 +- 中间件链(认证/日志) +- 广播消息 +- 连接/断开/消息事件 +- 与 HTTP 插件集成 + +## 使用 + +```python +ws = plugin_mgr.get("ws-api") + +# 注册消息路由 +ws.router.on_message("/chat", lambda client, msg: client.send({"echo": msg})) + +# 广播 +ws.server.broadcast({"type": "announcement", "data": "服务器维护通知"}) + +# 获取客户端列表 +clients = ws.server.get_clients() +``` + +## 事件 + +```python +# 通过 plugin-bridge 订阅 WS 事件 +bridge = plugin_mgr.get("plugin-bridge") +bridge.event_bus.on("ws.connect", lambda event: print(f"新连接: {event.client.path}")) +bridge.event_bus.on("ws.message", lambda event: print(f"消息: {event.message}")) +bridge.event_bus.on("ws.disconnect", lambda event: print(f"断开: {event.client.id}")) +``` + +## 配置 + +```json +{ + "config": { + "args": { + "host": "0.0.0.0", + "port": 8081 + } + } +} +``` diff --git a/store/@{FutureOSS}/ws-api/__pycache__/events.cpython-313.pyc b/store/@{FutureOSS}/ws-api/__pycache__/events.cpython-313.pyc new file mode 100644 index 0000000..581a6bf Binary files /dev/null and b/store/@{FutureOSS}/ws-api/__pycache__/events.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/ws-api/__pycache__/main.cpython-313.pyc b/store/@{FutureOSS}/ws-api/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..fe5849d Binary files /dev/null and b/store/@{FutureOSS}/ws-api/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/ws-api/__pycache__/middleware.cpython-313.pyc b/store/@{FutureOSS}/ws-api/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000..5a94e6e Binary files /dev/null and b/store/@{FutureOSS}/ws-api/__pycache__/middleware.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/ws-api/__pycache__/router.cpython-313.pyc b/store/@{FutureOSS}/ws-api/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000..30732b3 Binary files /dev/null and b/store/@{FutureOSS}/ws-api/__pycache__/router.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/ws-api/__pycache__/server.cpython-313.pyc b/store/@{FutureOSS}/ws-api/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..b55f7f1 Binary files /dev/null and b/store/@{FutureOSS}/ws-api/__pycache__/server.cpython-313.pyc differ diff --git a/store/@{FutureOSS}/ws-api/events.py b/store/@{FutureOSS}/ws-api/events.py new file mode 100644 index 0000000..73100ea --- /dev/null +++ b/store/@{FutureOSS}/ws-api/events.py @@ -0,0 +1,23 @@ +"""WebSocket 事件定义""" +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class WsEvent: + """WebSocket 事件""" + type: str + client: Any = None + path: str = "" + message: str = "" + context: dict[str, Any] = field(default_factory=dict) + + +# 事件类型常量 +EVENT_CONNECT = "ws.connect" +EVENT_DISCONNECT = "ws.disconnect" +EVENT_MESSAGE = "ws.message" +EVENT_ERROR = "ws.error" +EVENT_SUBSCRIBE = "ws.subscribe" +EVENT_UNSUBSCRIBE = "ws.unsubscribe" +EVENT_BROADCAST = "ws.broadcast" diff --git a/store/@{FutureOSS}/ws-api/main.py b/store/@{FutureOSS}/ws-api/main.py new file mode 100644 index 0000000..28a03dc --- /dev/null +++ b/store/@{FutureOSS}/ws-api/main.py @@ -0,0 +1,30 @@ +"""WebSocket API 插件入口 - 简化版""" +from oss.plugin.types import Plugin, register_plugin_type + + +class WsApiPlugin(Plugin): + """WebSocket API 插件""" + + def __init__(self): + self._running = False + + def init(self, deps: dict = None): + """初始化""" + print("[ws-api] 初始化完成") + + def start(self): + """启动""" + self._running = True + print("[ws-api] 已启动") + + def stop(self): + """停止""" + self._running = False + print("[ws-api] 已停止") + + +register_plugin_type("WsApiPlugin", WsApiPlugin) + + +def New(): + return WsApiPlugin() diff --git a/store/@{FutureOSS}/ws-api/manifest.json b/store/@{FutureOSS}/ws-api/manifest.json new file mode 100644 index 0000000..11f5f8b --- /dev/null +++ b/store/@{FutureOSS}/ws-api/manifest.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "name": "ws-api", + "version": "1.0.0", + "author": "FutureOSS", + "description": "WebSocket API 服务 - 实时双向通信", + "type": "protocol" + }, + "config": { + "enabled": true, + "args": { + "host": "0.0.0.0", + "port": 8081 + } + }, + "dependencies": [], + "permissions": [] +} diff --git a/store/@{FutureOSS}/ws-api/middleware.py b/store/@{FutureOSS}/ws-api/middleware.py new file mode 100644 index 0000000..9d3e7de --- /dev/null +++ b/store/@{FutureOSS}/ws-api/middleware.py @@ -0,0 +1,41 @@ +"""WebSocket 中间件链""" +from typing import Callable, Optional, Any + + +class WsMiddleware: + """WebSocket 中间件基类""" + async def process(self, client: Any, message: str, next_fn: Callable) -> Optional[str]: + """处理消息""" + return await next_fn() + + +class AuthMiddleware(WsMiddleware): + """认证中间件""" + async def process(self, client, message, next_fn): + # 可以在这里验证 token + return await next_fn() + + +class WsMiddlewareChain: + """WebSocket 中间件链""" + + def __init__(self): + self.middlewares: list[WsMiddleware] = [] + + def add(self, middleware: WsMiddleware): + """添加中间件""" + self.middlewares.append(middleware) + + async def run(self, client, message) -> Optional[str]: + """执行中间件链""" + idx = 0 + + async def next_fn(): + nonlocal idx + if idx < len(self.middlewares): + mw = self.middlewares[idx] + idx += 1 + return await mw.process(client, message, next_fn) + return message + + return await next_fn() diff --git a/store/@{FutureOSS}/ws-api/router.py b/store/@{FutureOSS}/ws-api/router.py new file mode 100644 index 0000000..f9b5ed7 --- /dev/null +++ b/store/@{FutureOSS}/ws-api/router.py @@ -0,0 +1,39 @@ +"""WebSocket 路由器""" +import json +import asyncio +from typing import Callable, Optional, Any +from .server import WsClient + + +class WsRoute: + """WebSocket 路由""" + def __init__(self, path: str, handler: Callable): + self.path = path + self.handler = handler + + +class WsRouter: + """WebSocket 路由器""" + + def __init__(self): + self.routes: dict[str, WsRoute] = {} + + def on_message(self, path: str, handler: Callable): + """注册消息路由""" + self.routes[path] = WsRoute(path, handler) + + async def handle(self, client: WsClient, path: str, message: str): + """处理消息""" + # 精确匹配 + if path in self.routes: + await self.routes[path].handler(client, message) + return + + # 前缀匹配 + for route_path, route in self.routes.items(): + if path.startswith(route_path): + await route.handler(client, message) + return + + # 无匹配路由 + await client.send({"error": "No handler for path", "path": path}) diff --git a/store/@{FutureOSS}/ws-api/server.py b/store/@{FutureOSS}/ws-api/server.py new file mode 100644 index 0000000..2a997fc --- /dev/null +++ b/store/@{FutureOSS}/ws-api/server.py @@ -0,0 +1,125 @@ +"""WebSocket 服务器核心""" +import asyncio +import websockets +import threading +import json +from typing import Any, Callable, Optional +from .events import WsEvent, EVENT_CONNECT, EVENT_DISCONNECT, EVENT_MESSAGE + + +class WsClient: + """WebSocket 客户端连接""" + + def __init__(self, websocket, path: str): + self.websocket = websocket + self.path = path + self.id = id(websocket) + self.closed = False + + async def send(self, message: Any): + """发送消息""" + if not self.closed: + data = json.dumps(message, ensure_ascii=False) if isinstance(message, dict) else str(message) + await self.websocket.send(data) + + async def close(self): + """关闭连接""" + self.closed = True + await self.websocket.close() + + +class WsServer: + """WebSocket 服务器""" + + def __init__(self, router, middleware, event_bus, host="0.0.0.0", port=8081): + self.host = host + self.port = port + self.router = router + self.middleware = middleware + self.event_bus = event_bus + self._server = None + self._loop = None + self._thread = None + self._clients: dict[int, WsClient] = {} + + def start(self): + """启动服务器""" + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def _run_loop(self): + """运行事件循环""" + asyncio.set_event_loop(self._loop) + start_server = websockets.serve( + self._handle_connection, + self.host, + self.port + ) + self._loop.run_until_complete(start_server) + self._loop.run_forever() + + async def _handle_connection(self, websocket, path=None): + """处理客户端连接(兼容 websockets 新旧版本)""" + # websockets 16.0+ 只传入 connection 参数 + if path is None: + # 新版本:从 websocket.request 获取路径 + try: + path = websocket.request.path + except AttributeError: + path = "/" + + client = WsClient(websocket, path) + self._clients[client.id] = client + + # 触发连接事件 + self.event_bus.emit(WsEvent( + type=EVENT_CONNECT, + client=client, + path=path + )) + + try: + async for message in websocket: + # 触发消息事件 + self.event_bus.emit(WsEvent( + type=EVENT_MESSAGE, + client=client, + path=path, + message=message + )) + + # 路由处理 + await self.router.handle(client, path, message) + + except websockets.exceptions.ConnectionClosed: + pass + finally: + del self._clients[client.id] + # 触发断开事件 + self.event_bus.emit(WsEvent( + type=EVENT_DISCONNECT, + client=client, + path=path + )) + + def stop(self): + """停止服务器""" + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + print("[ws-api] 服务器已停止") + + def broadcast(self, message: Any, exclude_client: int = None): + """广播消息""" + async def _broadcast(): + for client_id, client in self._clients.items(): + if exclude_client and client_id == exclude_client: + continue + await client.send(message) + + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(_broadcast(), self._loop) + + def get_clients(self) -> list[WsClient]: + """获取所有客户端""" + return list(self._clients.values()) diff --git a/website/architecture.html b/website/architecture.html new file mode 100644 index 0000000..0897094 --- /dev/null +++ b/website/architecture.html @@ -0,0 +1,94 @@ + + + + + + 架构设计 - Future OSS + + + + + + + + + + + + + + + + + +
+ +
+
+ 架构设计 +

插件驱动的分层架构

+

一切皆为插件,框架本身只提供核心能力

+
+ +
+
+
Future OSS 核心
+
+
插件管理
+
事件总线
+
消息总线
+
配置系统
+
+
+
+
+
+
+
协议插件
+
HTTP / WS
+
TCP / gRPC
+
UDP / Unix
+
+
+
工具插件
+
外部进程
+
脚本执行
+
依赖管理
+
+
+
中间件插件
+
限流 / 认证
+
日志 / 压缩
+
缓存 / 重试
+
+
+
通知插件
+
Webhook
+
邮件 / 钉钉
+
Telegram
+
+
+
+
+ +
+

数据流

+
+
1外部请求进入协议插件
+
→
+
2中间件链处理(认证/限流)
+
→
+
3业务插件执行逻辑
+
→
+
4发布事件 + 返回响应
+
+
+
+ + + + + + + + diff --git a/website/community/UPDATE_PROFILE.md b/website/community/UPDATE_PROFILE.md new file mode 100644 index 0000000..6172ace --- /dev/null +++ b/website/community/UPDATE_PROFILE.md @@ -0,0 +1,134 @@ +# 用户个人主页功能更新 + +## 改动概述 + +为用户社区添加了完整的个人主页系统,包括用户信息展示、文章管理、个人简介编辑等功能。 + +## 新增文件 + +### 1. `profile.php` - 用户个人主页 +**功能:** +- 显示用户头像、用户名、角色徽章 +- 显示用户个人简介(bio) +- 显示注册时间和邮箱(仅本人可见) +- 统计数据卡片:文章数、回复数、总浏览、总点赞 +- 最近发表的文章列表(最新 10 篇) +- 最近的回复列表(最新 5 条) +- 支持查看其他用户的主页(通过 `?id=用户ID` 参数) +- 本人访问时显示"编辑资料"按钮 + +**访问方式:** +- `profile.php` - 查看当前登录用户的个人主页 +- `profile.php?id=123` - 查看指定用户的个人主页 + +### 2. `edit-profile.php` - 编辑个人资料 +**功能:** +- 编辑个人简介(bio) +- 实时预览功能 +- 显示用户名和邮箱(只读) +- 保存成功后显示提示信息 + +**访问方式:** +- 从个人主页点击"编辑资料"按钮进入 + +### 3. `my-posts.php` - 我的文章(已更新) +**更新内容:** +- 支持查看其他用户的文章(通过 `?id=用户ID` 参数) +- 只有文章作者才能看到"编辑"和"删除"按钮 +- 页面标题动态显示用户名 + +### 4. `migrate-add-bio.php` - 数据库迁移脚本 +**用途:** +- 为现有的 `users` 表添加 `bio` 字段 +- 检查字段是否已存在,避免重复添加 + +**运行方式:** +```bash +php website/community/migrate-add-bio.php +``` + +## 修改的文件 + +### 1. `includes/dock.php` +**改动:** +- 用户头像链接从 `#` 改为 `profile.php`,点击跳转到个人主页 +- 用户面板新增"个人主页"菜单项 +- 保留"我的文章"菜单项,并显示文章数量徽章 + +### 2. `api/auth.php` +**改动:** +- 新增 `my-post-count` API 端点 +- 用于获取当前登录用户的文章数量 +- 在用户面板中实时显示文章数徽章 + +### 3. `css/dock.css` +**改动:** +- 新增 `.dock-user-avatar` 样式 +- 用户头像图标显示为青色高亮 +- 鼠标悬停时有缩放和背景效果 + +### 4. `assets/css/dock-popover.css` +**改动:** +- 新增 `.popover-menu` 菜单区域样式 +- 新增 `.menu-item` 菜单项样式(带图标、文字、徽章) +- 新增 `.menu-badge` 徽章样式 + +### 5. `assets/js/dock-popover.js` +**改动:** +- 新增 `fetchMyPostCount()` 函数 +- 页面加载时自动获取并显示用户文章数量 +- 保留原有的退登和注销功能 + +### 6. `schema.sql` +**改动:** +- `users` 表新增 `bio TEXT` 字段 +- 用于存储用户的个人简介 + +## 使用方法 + +### 1. 运行数据库迁移 +首次部署需要运行迁移脚本添加 bio 字段: +```bash +cd website/community +php migrate-add-bio.php +``` + +### 2. 访问个人主页 +- 点击左侧 Dock 栏的用户头像图标 +- 或访问 `community/profile.php` + +### 3. 编辑个人简介 +- 在个人主页点击"编辑资料"按钮 +- 或访问 `community/edit-profile.php` + +### 4. 查看我的文章 +- 在用户面板中点击"我的文章" +- 或访问 `community/my-posts.php` + +### 5. 查看其他用户的主页 +- 访问 `community/profile.php?id=用户ID` + +## 技术细节 + +### 数据库查询优化 +- 使用子查询统计回复数,避免 JOIN 导致的性能问题 +- 使用 SUM 聚合函数统计总浏览和总点赞 +- 所有查询都使用参数化查询防止 SQL 注入 + +### 安全性 +- 所有用户输入都经过 `htmlspecialchars()` 转义 +- 只有本人才能编辑和删除自己的文章 +- Session 验证确保用户只能访问自己的数据 + +### 响应式设计 +- 适配桌面端和移动端 +- 小屏幕时自动调整布局和字体大小 +- Dock 栏在移动端自动移到底部 + +## 后续可扩展功能 + +1. **头像上传** - 支持用户上传自定义头像 +2. **关注系统** - 用户可以互相关注 +3. **成就徽章** - 根据用户活跃度颁发徽章 +4. **社交链接** - 添加 GitHub、Twitter 等社交链接 +5. **活动日志** - 记录用户的登录、发帖等活动 diff --git a/website/community/add-title-system.sql b/website/community/add-title-system.sql new file mode 100644 index 0000000..6bba69e --- /dev/null +++ b/website/community/add-title-system.sql @@ -0,0 +1,25 @@ +-- 添加称号系统到 users 表 +ALTER TABLE users ADD COLUMN IF NOT EXISTS title VARCHAR(100) DEFAULT '' COMMENT '用户称号'; + +-- 设置 admin 用户的称号 +UPDATE users SET title = '你猜为什么是DeepSeek' WHERE role = 'admin' AND username = 'admin'; + +-- 创建称号配置表(可选,用于管理称号) +CREATE TABLE IF NOT EXISTS titles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE COMMENT '称号名称', + color VARCHAR(7) DEFAULT '#06b6d4' COMMENT '称号颜色', + description VARCHAR(255) DEFAULT '' COMMENT '称号描述', + is_admin_only TINYINT(1) DEFAULT 0 COMMENT '是否仅管理员可用', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 插入预设称号 +INSERT IGNORE INTO titles (name, color, description, is_admin_only) VALUES +('你猜为什么是DeepSeek', '#f59e0b', '神秘的管理称号', 1), +('管理员', '#ef4444', '网站管理员', 1), +('版主', '#22c55e', '社区版主', 1), +('活跃用户', '#3b82f6', '经常发帖的活跃用户', 0), +('新手', '#6b7280', '新加入的用户', 0), +('技术达人', '#8b5cf6', '技术方面的大神', 0), +('社区元老', '#f97316', '在很久的时间前就加入社区的用户', 0); diff --git a/website/community/api/auth.php b/website/community/api/auth.php new file mode 100644 index 0000000..63282fe --- /dev/null +++ b/website/community/api/auth.php @@ -0,0 +1,286 @@ + false, 'message' => '请求方法不允许']); + exit; +} + +if ($action === 'login') { + handleLogin(); +} elseif ($action === 'register') { + handleRegister(); +} elseif ($action === 'logout') { + handleLogout(); +} elseif ($action === 'check') { + handleCheck(); +} elseif ($action === 'my-post-count') { + handleMyPostCount(); +} elseif ($action === 'current-user') { + handleCurrentUser(); +} else { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '无效的操作类型']); +} + +/** + * 处理登录 + */ +function handleLogin() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['username']) || empty($input['password'])) { + echo json_encode(['success' => false, 'message' => '用户名和密码不能为空']); + return; + } + + $username = trim($input['username']); + $password = $input['password']; + $remember = $input['remember'] ?? false; + + try { + $db = Database::getInstance(); + + // 查询用户(支持用户名或邮箱登录) + $user = $db->fetchOne( + "SELECT id, username, email, password_hash, role, avatar FROM users WHERE username = ? OR email = ?", + [$username, $username] + ); + + if (!$user) { + echo json_encode(['success' => false, 'message' => '用户名或密码错误']); + return; + } + + // 验证密码 + if (!password_verify($password, $user['password_hash'])) { + echo json_encode(['success' => false, 'message' => '用户名或密码错误']); + return; + } + + // 设置 session + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + $_SESSION['avatar'] = $user['avatar']; + + // 如果勾选记住我,设置更长的 session 生命周期 + if ($remember) { + ini_set('session.gc_maxlifetime', 30 * 24 * 60 * 60); // 30天 + session_set_cookie_params(30 * 24 * 60 * 60); + } + + echo json_encode([ + 'success' => true, + 'message' => '登录成功', + 'user' => [ + 'id' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'], + 'avatar' => $user['avatar'] + ] + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 处理注册 + */ +function handleRegister() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['username']) || empty($input['email']) || empty($input['password'])) { + echo json_encode(['success' => false, 'message' => '所有字段都不能为空']); + return; + } + + $username = trim($input['username']); + $email = trim($input['email']); + $password = $input['password']; + + // 验证用户名格式 + if (!preg_match('/^[a-zA-Z0-9_]{3,50}$/', $username)) { + echo json_encode(['success' => false, 'message' => '用户名只能包含字母、数字和下划线,长度 3-50 个字符']); + return; + } + + // 验证邮箱格式 + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + echo json_encode(['success' => false, 'message' => '邮箱格式不正确']); + return; + } + + // 验证密码长度 + if (strlen($password) < 6) { + echo json_encode(['success' => false, 'message' => '密码长度至少 6 个字符']); + return; + } + + try { + $db = Database::getInstance(); + + // 检查用户名是否已存在 + $existingUser = $db->fetchOne("SELECT id FROM users WHERE username = ?", [$username]); + if ($existingUser) { + echo json_encode(['success' => false, 'message' => '用户名已被使用']); + return; + } + + // 检查邮箱是否已存在 + $existingEmail = $db->fetchOne("SELECT id FROM users WHERE email = ?", [$email]); + if ($existingEmail) { + echo json_encode(['success' => false, 'message' => '邮箱已被注册']); + return; + } + + // 密码哈希 + $passwordHash = password_hash($password, PASSWORD_DEFAULT); + + // 插入新用户 + $db->query( + "INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'member')", + [$username, $email, $passwordHash] + ); + + $userId = $db->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'message' => '注册成功', + 'user' => [ + 'id' => $userId, + 'username' => $username, + 'role' => 'member' + ] + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 处理登出 + */ +function handleLogout() { + session_destroy(); + echo json_encode(['success' => true, 'message' => '已成功退出']); +} + +/** + * 检查登录状态 + */ +function handleCheck() { + if (isset($_SESSION['user_id'])) { + echo json_encode([ + 'success' => true, + 'logged_in' => true, + 'user' => [ + 'id' => $_SESSION['user_id'], + 'username' => $_SESSION['username'], + 'role' => $_SESSION['role'] ?? 'member', + 'avatar' => $_SESSION['avatar'] ?? '' + ] + ]); + } else { + echo json_encode([ + 'success' => true, + 'logged_in' => false + ]); + } +} + +/** + * 获取用户文章数量 + */ +function handleMyPostCount() { + if (!isset($_SESSION['user_id'])) { + echo json_encode(['success' => false, 'message' => '未登录']); + return; + } + + try { + $db = Database::getInstance(); + $count = $db->fetchOne( + "SELECT COUNT(*) as count FROM posts WHERE user_id = ?", + [$_SESSION['user_id']] + )['count']; + + echo json_encode([ + 'success' => true, + 'count' => (int)$count + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 获取当前登录用户信息(用于轮询) + */ +function handleCurrentUser() { + if (!isset($_SESSION['user_id'])) { + echo json_encode(['success' => false, 'message' => '未登录']); + return; + } + + try { + $db = Database::getInstance(); + $user = $db->fetchOne( + "SELECT id, username, email, avatar, role, title, bio, created_at FROM users WHERE id = ?", + [$_SESSION['user_id']] + ); + + if (!$user) { + echo json_encode(['success' => false, 'message' => '用户不存在']); + return; + } + + // 获取统计数据 + $stats = $db->fetchOne( + "SELECT + (SELECT COUNT(*) FROM posts WHERE user_id = ?) as post_count, + (SELECT COUNT(*) FROM replies WHERE user_id = ?) as reply_count", + [$user['id'], $user['id']] + ); + + echo json_encode([ + 'success' => true, + 'user' => $user, + 'stats' => [ + 'post_count' => (int)$stats['post_count'], + 'reply_count' => (int)$stats['reply_count'] + ], + 'permissions' => [ + 'can_manage_users' => in_array($user['role'], ['admin']), + 'can_manage_posts' => in_array($user['role'], ['admin', 'moderator']), + 'can_pin_posts' => in_array($user['role'], ['admin', 'moderator']), + 'can_lock_posts' => in_array($user['role'], ['admin', 'moderator']), + 'can_delete_any_post' => in_array($user['role'], ['admin']), + 'can_manage_titles' => in_array($user['role'], ['admin']) + ] + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} diff --git a/website/community/api/index.php b/website/community/api/index.php new file mode 100644 index 0000000..d939925 --- /dev/null +++ b/website/community/api/index.php @@ -0,0 +1,112 @@ +fetchAll( + "SELECT p.*, u.username, u.avatar, c.name as category_name + FROM posts p + JOIN users u ON p.user_id = u.id + JOIN categories c ON p.category_id = c.id + WHERE p.category_id = ? + ORDER BY p.is_pinned DESC, p.created_at DESC + LIMIT ? OFFSET ?", + [$categoryId, $limit, $offset] + ); + $total = $db->fetchOne("SELECT COUNT(*) as count FROM posts WHERE category_id = ?", [$categoryId])['count']; + } else { + $posts = $db->fetchAll( + "SELECT p.*, u.username, u.avatar, c.name as category_name + FROM posts p + JOIN users u ON p.user_id = u.id + JOIN categories c ON p.category_id = c.id + ORDER BY p.is_pinned DESC, p.created_at DESC + LIMIT ? OFFSET ?", + [$limit, $offset] + ); + $total = $db->fetchOne("SELECT COUNT(*) as count FROM posts")['count']; + } + + echo json_encode([ + 'posts' => $posts, + 'total' => $total, + 'pages' => ceil($total / $limit) + ]); + break; + + case 'post': + $id = (int)($_GET['id'] ?? 0); + $post = $db->fetchOne( + "SELECT p.*, u.username, u.avatar, u.role, c.name as category_name, c.slug as category_slug + FROM posts p + JOIN users u ON p.user_id = u.id + JOIN categories c ON p.category_id = c.id + WHERE p.id = ?", + [$id] + ); + + if (!$post) { + http_response_code(404); + echo json_encode(['error' => '帖子不存在']); + exit; + } + + // 更新浏览数 + $db->query("UPDATE posts SET views = views + 1 WHERE id = ?", [$id]); + $post['views']++; + + // 获取回复 + $replies = $db->fetchAll( + "SELECT r.*, u.username, u.avatar + FROM replies r + JOIN users u ON r.user_id = u.id + WHERE r.post_id = ? + ORDER BY r.is_solution DESC, r.created_at ASC", + [$id] + ); + + echo json_encode(['post' => $post, 'replies' => $replies]); + break; + + case 'categories': + $categories = $db->fetchAll("SELECT * FROM categories ORDER BY sort_order ASC"); + echo json_encode(['categories' => $categories]); + break; + + case 'stats': + $stats = [ + 'posts' => $db->fetchOne("SELECT COUNT(*) as count FROM posts")['count'], + 'replies' => $db->fetchOne("SELECT COUNT(*) as count FROM replies")['count'], + 'users' => $db->fetchOne("SELECT COUNT(*) as count FROM users")['count'], + 'hot_posts' => $db->fetchAll("SELECT id, title, views, likes FROM posts ORDER BY views DESC LIMIT 5"), + ]; + echo json_encode($stats); + break; + + default: + echo json_encode(['error' => '未知操作']); + } +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/website/community/api/posts.php b/website/community/api/posts.php new file mode 100644 index 0000000..bd4763b --- /dev/null +++ b/website/community/api/posts.php @@ -0,0 +1,340 @@ + false, 'message' => '请求方法不允许']); + exit; +} + +$action = $_GET['action'] ?? ''; + +// 检查登录状态(除了查看操作) +$requireAuth = in_array($action, ['create', 'update', 'delete', 'pin', 'lock']); +if ($requireAuth && !isset($_SESSION['user_id'])) { + echo json_encode(['success' => false, 'message' => '请先登录']); + exit; +} + +switch ($action) { + case 'create': + handleCreatePost(); + break; + case 'update': + handleUpdatePost(); + break; + case 'delete': + handleDeletePost(); + break; + case 'pin': + handlePinPost(); + break; + case 'lock': + handleLockPost(); + break; + default: + http_response_code(400); + echo json_encode(['success' => false, 'message' => '无效的操作类型']); +} + +/** + * 创建帖子 + */ +function handleCreatePost() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['title']) || empty($input['content']) || empty($input['category_id'])) { + echo json_encode(['success' => false, 'message' => '标题、内容和分类不能为空']); + return; + } + + $title = trim($input['title']); + $content = trim($input['content']); + $categoryId = (int)$input['category_id']; + $tags = $input['tags'] ?? []; + + // 验证标题长度 + if (mb_strlen($title) < 5 || mb_strlen($title) > 200) { + echo json_encode(['success' => false, 'message' => '标题长度必须在 5-200 个字符之间']); + return; + } + + // 验证内容长度 + if (mb_strlen($content) < 10) { + echo json_encode(['success' => false, 'message' => '内容至少 10 个字符']); + return; + } + + try { + $db = Database::getInstance(); + $userId = $_SESSION['user_id']; + $userRole = $_SESSION['role'] ?? 'member'; + + // 验证分类是否存在 + $category = $db->fetchOne("SELECT * FROM categories WHERE id = ?", [$categoryId]); + if (!$category) { + echo json_encode(['success' => false, 'message' => '分类不存在']); + return; + } + + // 公告分类:禁止通过 API 发帖(只能通过 SQL 直接插入) + if ($category['slug'] === 'announcements') { + echo json_encode(['success' => false, 'message' => '公告不能通过发帖功能创建,请联系管理员通过数据库添加']); + return; + } + + // 生成 slug + $slug = generateSlug($title); + + // 检查 slug 是否重复 + $existing = $db->fetchOne("SELECT id FROM posts WHERE slug = ?", [$slug]); + if ($existing) { + $slug .= '-' . time(); + } + + // 插入帖子 + $db->query( + "INSERT INTO posts (user_id, category_id, title, slug, content) VALUES (?, ?, ?, ?, ?)", + [$userId, $categoryId, $title, $slug, $content] + ); + + $postId = $db->lastInsertId(); + + // 保存标签 + if (!empty($tags)) { + saveTags($db, $postId, $tags); + } + + echo json_encode([ + 'success' => true, + 'message' => '发帖成功', + 'post_id' => $postId + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 更新帖子 + */ +function handleUpdatePost() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id']) || empty($input['title']) || empty($input['content'])) { + echo json_encode(['success' => false, 'message' => '标题和内容不能为空']); + return; + } + + $postId = (int)$input['id']; + $title = trim($input['title']); + $content = trim($input['content']); + $tags = $input['tags'] ?? []; + + try { + $db = Database::getInstance(); + $userId = $_SESSION['user_id']; + + // 检查帖子是否存在且属于当前用户 + $post = $db->fetchOne("SELECT user_id FROM posts WHERE id = ?", [$postId]); + if (!$post) { + echo json_encode(['success' => false, 'message' => '帖子不存在']); + return; + } + + // 只有作者可以编辑 + if ($post['user_id'] != $userId) { + echo json_encode(['success' => false, 'message' => '无权编辑此帖子']); + return; + } + + // 更新帖子 + $db->query( + "UPDATE posts SET title = ?, content = ?, updated_at = NOW() WHERE id = ?", + [$title, $content, $postId] + ); + + // 更新标签 + if (!empty($tags)) { + saveTags($db, $postId, $tags); + } + + echo json_encode([ + 'success' => true, + 'message' => '更新成功' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 删除帖子 + */ +function handleDeletePost() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + echo json_encode(['success' => false, 'message' => '帖子 ID 不能为空']); + return; + } + + $postId = (int)$input['id']; + + try { + $db = Database::getInstance(); + $userId = $_SESSION['user_id']; + $userRole = $_SESSION['role'] ?? 'member'; + + // 检查帖子是否存在 + $post = $db->fetchOne("SELECT user_id FROM posts WHERE id = ?", [$postId]); + if (!$post) { + echo json_encode(['success' => false, 'message' => '帖子不存在']); + return; + } + + // 只有作者或管理员可以删除 + if ($post['user_id'] != $userId && !in_array($userRole, ['admin', 'moderator'])) { + echo json_encode(['success' => false, 'message' => '无权删除此帖子']); + return; + } + + // 删除帖子(外键级联删除回复和点赞) + $db->query("DELETE FROM posts WHERE id = ?", [$postId]); + + echo json_encode([ + 'success' => true, + 'message' => '删除成功' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 置顶/取消置顶 + */ +function handlePinPost() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + echo json_encode(['success' => false, 'message' => '帖子 ID 不能为空']); + return; + } + + $postId = (int)$input['id']; + $pinned = (bool)($input['pinned'] ?? false); + + try { + $db = Database::getInstance(); + $userRole = $_SESSION['role'] ?? 'member'; + + // 只有管理员或版主可以置顶 + if (!in_array($userRole, ['admin', 'moderator'])) { + echo json_encode(['success' => false, 'message' => '无权置顶帖子']); + return; + } + + $db->query("UPDATE posts SET is_pinned = ? WHERE id = ?", [(int)$pinned, $postId]); + + echo json_encode([ + 'success' => true, + 'message' => $pinned ? '已置顶' : '已取消置顶' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 锁定/解锁帖子 + */ +function handleLockPost() { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + echo json_encode(['success' => false, 'message' => '帖子 ID 不能为空']); + return; + } + + $postId = (int)$input['id']; + $locked = (bool)($input['locked'] ?? false); + + try { + $db = Database::getInstance(); + $userRole = $_SESSION['role'] ?? 'member'; + + // 只有管理员或版主可以锁定 + if (!in_array($userRole, ['admin', 'moderator'])) { + echo json_encode(['success' => false, 'message' => '无权锁定帖子']); + return; + } + + $db->query("UPDATE posts SET is_locked = ? WHERE id = ?", [(int)$locked, $postId]); + + echo json_encode([ + 'success' => true, + 'message' => $locked ? '已锁定' : '已解锁' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器错误:' . $e->getMessage()]); + } +} + +/** + * 保存标签 + */ +function saveTags($db, $postId, $tags) { + // 先删除旧标签关联 + $db->query("DELETE FROM post_tags WHERE post_id = ?", [$postId]); + + foreach ($tags as $tagName) { + $tagName = trim($tagName); + if (empty($tagName)) continue; + + // 查找或创建标签 + $slug = strtolower(preg_replace('/[^a-zA-Z0-9\x{4e00}-\x{9fa5}]/u', '-', $tagName)); + $tag = $db->fetchOne("SELECT id FROM tags WHERE name = ?", [$tagName]); + + if (!$tag) { + $db->query( + "INSERT INTO tags (name, slug) VALUES (?, ?)", + [$tagName, $slug] + ); + $tagId = $db->lastInsertId(); + } else { + $tagId = $tag['id']; + } + + // 关联标签 + $db->query( + "INSERT IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)", + [$postId, $tagId] + ); + } +} + +/** + * 生成 slug + */ +function generateSlug($title) { + // 简单处理:移除特殊字符,替换空格为连字符 + $slug = preg_replace('/[^\p{L}\p{N}\s]/u', '', $title); + $slug = preg_replace('/\s+/', '-', $slug); + $slug = mb_strtolower($slug, 'UTF-8'); + return mb_substr($slug, 0, 100); +} diff --git a/website/community/assets/css/auth.css b/website/community/assets/css/auth.css new file mode 100644 index 0000000..a559b24 --- /dev/null +++ b/website/community/assets/css/auth.css @@ -0,0 +1,363 @@ +/* ============================================ + OSS Community 认证页面样式 + 与官网风格保持一致 + ============================================ */ + +/* 认证页面布局 */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + margin-left: 80px; + position: relative; + z-index: 1; +} + +.auth-container { + width: 100%; + max-width: 480px; + position: relative; +} + +/* 认证卡片 */ +.auth-card { + background: rgba(15, 23, 42, 0.8); + backdrop-filter: blur(20px); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 16px; + padding: 40px; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(99, 102, 241, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + animation: cardSlideUp 0.6s ease-out; +} + +@keyframes cardSlideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 认证头部 */ +.auth-header { + text-align: center; + margin-bottom: 32px; +} + +.auth-logo { + width: 64px; + height: 64px; + margin: 0 auto 20px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4); +} + +.auth-logo svg { + width: 32px; + height: 32px; + color: white; +} + +.auth-title { + font-size: 28px; + font-weight: 700; + margin: 0 0 8px 0; + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.auth-subtitle { + color: #94a3b8; + font-size: 14px; + margin: 0; +} + +/* 表单样式 */ +.auth-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-label { + font-size: 14px; + font-weight: 500; + color: #e2e8f0; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 14px; + width: 18px; + height: 18px; + color: #64748b; + pointer-events: none; + transition: color 0.2s; +} + +.form-input { + width: 100%; + padding: 12px 14px 12px 44px; + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 8px; + color: #e2e8f0; + font-size: 14px; + transition: all 0.2s; +} + +.form-input::placeholder { + color: #64748b; +} + +.form-input:focus { + outline: none; + border-color: #6366f1; + background: rgba(30, 41, 59, 0.8); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.form-input:focus + .input-icon, +.form-input:focus ~ .input-icon { + color: #6366f1; +} + +.input-hint { + font-size: 12px; + color: #64748b; + margin-top: 4px; +} + +/* 切换密码可见性按钮 */ +.toggle-password { + position: absolute; + right: 12px; + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: #64748b; + transition: color 0.2s; +} + +.toggle-password:hover { + color: #6366f1; +} + +.toggle-password svg { + width: 18px; + height: 18px; +} + +/* 表单选项 */ +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #94a3b8; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #6366f1; + cursor: pointer; +} + +.forgot-link { + color: #6366f1; + text-decoration: none; + transition: color 0.2s; +} + +.forgot-link:hover { + color: #8b5cf6; +} + +/* 警告框 */ +.alert { + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + display: none; +} + +.alert-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; +} + +.alert-success { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #86efac; +} + +/* 按钮样式 */ +.btn-auth { + width: 100%; + padding: 14px 24px; + font-size: 16px; + font-weight: 600; + margin-top: 8px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-auth:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-spinner { + width: 20px; + height: 20px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 认证页脚 */ +.auth-footer { + text-align: center; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid rgba(99, 102, 241, 0.2); + color: #94a3b8; + font-size: 14px; +} + +.auth-link { + color: #6366f1; + text-decoration: none; + font-weight: 500; + transition: color 0.2s; +} + +.auth-link:hover { + color: #8b5cf6; + text-decoration: underline; +} + +/* 装饰元素 */ +.auth-decoration { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; + z-index: -1; +} + +.deco-circle { + position: absolute; + border-radius: 50%; + background: radial-gradient(circle, rgba(99, 102, 241, 0.3) 0%, transparent 70%); +} + +.deco-circle-1 { + width: 400px; + height: 400px; + top: -100px; + right: -100px; + animation: float 20s ease-in-out infinite; +} + +.deco-circle-2 { + width: 300px; + height: 300px; + bottom: -50px; + left: 10%; + background: radial-gradient(circle, rgba(139, 92, 246, 0.3) 0%, transparent 70%); + animation: float 15s ease-in-out infinite reverse; +} + +.deco-circle-3 { + width: 200px; + height: 200px; + top: 40%; + right: 15%; + background: radial-gradient(circle, rgba(6, 182, 212, 0.2) 0%, transparent 70%); + animation: float 18s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { + transform: translate(0, 0); + } + 25% { + transform: translate(20px, -20px); + } + 50% { + transform: translate(-10px, 15px); + } + 75% { + transform: translate(15px, 10px); + } +} + +/* 响应式 */ +@media (max-width: 640px) { + .auth-page { + margin-left: 0; + padding: 20px 16px; + } + + .auth-card { + padding: 32px 24px; + } + + .auth-title { + font-size: 24px; + } + + .form-options { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } +} diff --git a/website/community/assets/css/community.css b/website/community/assets/css/community.css new file mode 100644 index 0000000..d3c49e7 --- /dev/null +++ b/website/community/assets/css/community.css @@ -0,0 +1,572 @@ +/* OSS Community 样式 - 已对齐官网视觉规范 */ +:root { + --bg: #030712; + --bg-card: rgba(255, 255, 255, 0.02); + --border: rgba(255, 255, 255, 0.05); + --border-hover: rgba(6, 182, 212, 0.3); + --cyan: #06b6d4; + --cyan-light: #22d3ee; + --text: #fff; + --text-secondary: #9ca3af; + --text-muted: #6b7280; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Inter', sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + overflow-x: hidden; +} + +/* 全局背景网格 */ +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(6, 182, 212, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(6, 182, 212, 0.03) 1px, transparent 1px); + background-size: 60px 60px; + pointer-events: none; + z-index: -1; +} + +/* 头部导航 */ +.comm-header { + position: sticky; + top: 0; + z-index: 100; + background: rgba(3, 7, 18, 0.8); + backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + transition: border-color 0.3s; +} + +.header-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 24px; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; + gap: 24px; +} + +.back-link { + color: var(--text-muted); + text-decoration: none; + font-size: 14px; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 4px; +} + +.back-link:hover { + color: var(--cyan-light); + transform: translateX(-4px); +} + +.site-title { + font-size: 18px; + font-weight: 700; + background: linear-gradient(135deg, var(--cyan), #3b82f6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: 0.02em; +} + +.btn { + padding: 10px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 6px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--cyan), #3b82f6); + color: #fff; + box-shadow: 0 4px 12px rgba(6, 182, 212, 0.1); +} + +.btn-primary:hover { + box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4); + transform: translateY(-2px); +} + +/* 主布局 */ +.comm-main { + max-width: 1200px; + margin: 0 auto; + padding: 24px 100px 24px 24px; /* 右侧为 Dock 留空间 */ + min-height: calc(100vh - 64px); +} + +.comm-container { + display: grid; + grid-template-columns: 260px 1fr; + gap: 32px; + animation: fadeInUp 0.6s ease forwards; + opacity: 0; + transform: translateY(20px); +} + +@keyframes fadeInUp { + to { opacity: 1; transform: translateY(0); } +} + +/* 侧边栏 */ +.comm-sidebar { + position: sticky; + top: 88px; + height: fit-content; +} + +.sidebar-section { + background: rgba(10, 15, 30, 0.6); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; + margin-bottom: 20px; + backdrop-filter: blur(8px); + transition: all 0.3s ease; +} + +.sidebar-section:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.sidebar-section h3 { + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 16px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.category-list { list-style: none; } + +.category-list li { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 10px; + cursor: pointer; + transition: all 0.25s ease; + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; +} + +.category-list li:hover { + background: rgba(255, 255, 255, 0.04); + color: #fff; + transform: translateX(4px); +} + +.category-list li.active { + background: rgba(6, 182, 212, 0.1); + border-left: 3px solid var(--cyan); + color: var(--cyan-light); +} + +.cat-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; +} + +.cat-icon svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; +} + +.category-list li.active .cat-icon { opacity: 1; color: var(--cyan); } + +.cat-count { + font-size: 11px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.03); + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + text-align: center; +} + +.stat-item { + padding: 10px 4px; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; +} + +.stat-num { + display: block; + font-size: 20px; + font-weight: 800; + color: var(--cyan-light); + margin-bottom: 4px; +} + +.stat-label { + font-size: 10px; + color: var(--text-muted); + font-weight: 500; + text-transform: uppercase; +} + +/* 内容区 */ +.comm-content { min-width: 0; } + +.content-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.content-header h2 { + font-size: 22px; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + gap: 10px; +} + +.content-header h2::before { + content: ''; + display: block; + width: 4px; + height: 20px; + background: var(--cyan); + border-radius: 2px; +} + +.sort-options { + display: flex; + gap: 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px; +} + +.sort-btn { + padding: 6px 14px; + border-radius: 8px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.sort-btn:hover { color: #fff; } + +.sort-btn.active { + background: rgba(6, 182, 212, 0.15); + color: var(--cyan-light); + box-shadow: 0 2px 6px rgba(6, 182, 212, 0.1); +} + +/* 帖子列表 - 卡片动效 */ +.posts-list { display: flex; flex-direction: column; gap: 16px; } + +.post-card { + background: rgba(10, 15, 30, 0.6); + border: 1px solid var(--border); + border-radius: 16px; + padding: 24px; + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + cursor: pointer; + position: relative; + overflow: hidden; + animation: cardEnter 0.5s ease forwards; + opacity: 0; + transform: translateY(20px); +} + +@keyframes cardEnter { + to { opacity: 1; transform: translateY(0); } +} + +.post-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.05), transparent); + opacity: 0; + transition: opacity 0.4s; +} + +.post-card:hover { + border-color: var(--border-hover); + transform: translateY(-4px) scale(1.005); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4), 0 0 12px rgba(6, 182, 212, 0.1); +} + +.post-card:hover::before { opacity: 1; } + +.post-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 14px; + position: relative; + z-index: 1; +} + +.post-avatar { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, var(--cyan), #3b82f6); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 18px; + color: #fff; + box-shadow: 0 4px 10px rgba(6, 182, 212, 0.3); + flex-shrink: 0; +} + +.post-meta { flex: 1; min-width: 0; } + +.post-author { + font-weight: 600; + font-size: 14px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.post-time { + font-size: 12px; + color: var(--text-muted); +} + +.post-tags { display: flex; gap: 6px; flex-shrink: 0; } + +.tag { + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: rgba(6, 182, 212, 0.08); + border: 1px solid rgba(6, 182, 212, 0.15); + color: var(--cyan-light); + text-transform: uppercase; +} + +.pinned-badge { + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.3); + color: #fbbf24; + display: flex; + align-items: center; + gap: 4px; +} + +.post-title { + font-size: 17px; + font-weight: 700; + margin-bottom: 8px; + display: flex; + align-items: flex-start; + gap: 8px; + position: relative; + z-index: 1; + color: #fff; + transition: color 0.2s; +} + +.post-card:hover .post-title { color: var(--cyan-light); } + +.post-excerpt { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 14px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + position: relative; + z-index: 1; +} + +.post-stats { + display: flex; + gap: 20px; + font-size: 12px; + color: var(--text-muted); + font-weight: 500; + position: relative; + z-index: 1; +} + +.post-stats span { + display: flex; + align-items: center; + gap: 6px; + transition: color 0.2s; +} + +.post-stats svg { + width: 14px; + height: 14px; + opacity: 0.6; +} + +.post-card:hover .post-stats span { color: var(--text-secondary); } + +/* 空状态 / 加载动画 */ +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--text-muted); + animation: fadeIn 0.5s ease; +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(6, 182, 212, 0.1); + border-top-color: var(--cyan); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.4; +} + +/* 分页 */ +.pagination { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 32px; + padding-bottom: 32px; +} + +.page-btn { + width: 36px; + height: 36px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; +} + +.page-btn:hover { + background: rgba(6, 182, 212, 0.1); + border-color: var(--border-hover); + color: var(--cyan); + transform: translateY(-2px); +} + +.page-btn.active { + background: var(--cyan); + border-color: var(--cyan); + color: #fff; + box-shadow: 0 4px 12px rgba(6, 182, 212, 0.4); +} + +/* 响应式 */ +@media (max-width: 900px) { + .comm-container { + grid-template-columns: 1fr; + } + .comm-sidebar { + position: static; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + } + .stats-grid { margin-top: 0; } +} + +@media (max-width: 600px) { + .header-container { padding: 0 16px; } + .comm-main { padding: 16px; } + .content-header h2 { font-size: 18px; } + .sort-options { display: none; } + .post-card { padding: 16px; } + .post-title { font-size: 15px; } +} +/* 创建帖子按钮样式 - 区别于普通导航链接 */ +button.dock-item { + background: transparent; + border: none; + cursor: pointer; +} + +.dock-action-btn { + color: #10b981; /* 翡翠绿 - 代表创建/动作 */ + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.2); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.dock-action-btn:hover { + background: rgba(16, 185, 129, 0.2); + border-color: #10b981; + color: #34d399; + box-shadow: 0 0 12px rgba(16, 185, 129, 0.4); + transform: scale(1.1); +} diff --git a/website/community/assets/css/dock-popover.css b/website/community/assets/css/dock-popover.css new file mode 100644 index 0000000..3ca1507 --- /dev/null +++ b/website/community/assets/css/dock-popover.css @@ -0,0 +1,177 @@ +/* Dock 用户信息面板样式 */ + +.user-popover { + position: fixed; + z-index: 10001; + width: 260px; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(12px); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + opacity: 0; + visibility: hidden; + transform: translateY(10px) scale(0.98); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + color: #e2e8f0; + font-family: 'Inter', sans-serif; +} + +.user-popover.active { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + pointer-events: auto; +} + +.popover-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-bottom: 1px solid rgba(99, 102, 241, 0.1); +} + +.popover-avatar { + width: 44px; + height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + font-weight: 700; + color: white; + flex-shrink: 0; +} + +.popover-info { + flex: 1; + min-width: 0; +} + +.popover-name { + font-size: 14px; + font-weight: 600; + color: #f1f5f9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.popover-role { + font-size: 11px; + color: #94a3b8; + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255, 255, 255, 0.05); + padding: 2px 6px; + border-radius: 4px; + display: inline-block; +} + +.popover-menu { + padding: 8px; + border-bottom: 1px solid rgba(99, 102, 241, 0.1); +} + +.menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + color: #e2e8f0; + text-decoration: none; + transition: all 0.2s; + background: transparent; + border: none; + cursor: pointer; + width: 100%; +} + +.menu-item:hover { + background: rgba(99, 102, 241, 0.15); + color: #f1f5f9; +} + +.menu-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; + color: #94a3b8; +} + +.menu-item:hover svg { + color: #c7d2fe; +} + +.menu-item span { + flex: 1; + font-size: 13px; + font-weight: 500; +} + +.menu-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + background: rgba(99, 102, 241, 0.3); + color: #c7d2fe; + min-width: 24px; + text-align: center; +} + +.popover-footer { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.popover-btn { + width: 100%; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-logout { + background: rgba(99, 102, 241, 0.2); + color: #c7d2fe; + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.btn-logout:hover { + background: rgba(99, 102, 241, 0.3); + color: white; +} + +.btn-danger { + background: transparent; + color: #94a3b8; + border: 1px solid rgba(100, 116, 139, 0.2); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); +} + +.btn-danger svg { + width: 14px; + height: 14px; +} diff --git a/website/community/assets/css/editor.css b/website/community/assets/css/editor.css new file mode 100644 index 0000000..6d81291 --- /dev/null +++ b/website/community/assets/css/editor.css @@ -0,0 +1,325 @@ +/* OSS Community Editor - 优化版两栏布局 */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +.editor-page { + min-height: 100vh; + background: #0f172a; + display: flex; + flex-direction: column; +} + +.editor-container { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + padding-right: 100px; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +/* 顶部工具栏 */ +.editor-toolbar { + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 18px; + background: rgba(15, 23, 42, 0.9); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 12px; + margin-bottom: 20px; +} + +.toolbar-left { display: flex; align-items: center; gap: 12px; } + +.back-btn { + display: flex; align-items: center; gap: 6px; + color: #94a3b8; text-decoration: none; font-size: 14px; font-weight: 500; + transition: color 0.2s; +} +.back-btn:hover { color: #e2e8f0; } +.back-btn svg { width: 18px; height: 18px; } + +.toolbar-title { + font-size: 16px; font-weight: 700; + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; background-clip: text; +} + +.toolbar-right { display: flex; gap: 8px; } + +.btn { + display: flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 8px; font-size: 13px; + font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; +} +.btn svg { width: 16px; height: 16px; } + +.btn-outline { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; border: 1px solid rgba(100, 116, 139, 0.3); +} +.btn-outline:hover { background: rgba(100, 116, 139, 0.3); color: #e2e8f0; } + +.btn-primary { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: white; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} +.btn-primary:hover { box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4); transform: translateY(-1px); } + +/* 标题输入 */ +.editor-header { + flex-shrink: 0; + margin-bottom: 20px; +} + +.title-input { + width: 100%; + padding: 12px 16px; + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 10px; + color: #e2e8f0; + font-size: 24px; + font-weight: 700; + transition: all 0.2s; +} +.title-input:focus { + outline: none; border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} +.title-input::placeholder { color: #64748b; font-weight: 400; } + +/* 两栏主工作区 */ +.editor-workspace { + display: grid; + grid-template-columns: 1fr 280px; + gap: 20px; + flex: 1; + min-height: calc(100vh - 280px); +} + +/* 编辑器面板 */ +.editor-panel { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: rgba(30, 41, 59, 0.4); + border-bottom: 1px solid rgba(99, 102, 241, 0.1); +} + +.panel-title { + font-size: 12px; font-weight: 600; color: #94a3b8; + text-transform: uppercase; letter-spacing: 0.05em; +} + +.panel-actions { display: flex; gap: 4px; } + +.md-btn { + padding: 6px 8px; + background: transparent; border: 1px solid transparent; border-radius: 6px; + color: #64748b; cursor: pointer; transition: all 0.2s; +} +.md-btn:hover { + background: rgba(99, 102, 241, 0.1); + border-color: rgba(99, 102, 241, 0.2); + color: #e2e8f0; +} +.md-btn svg { width: 16px; height: 16px; } + +.editor-textarea { + flex: 1; + width: 100%; + padding: 18px; + background: transparent; + border: none; + color: #e2e8f0; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + line-height: 1.8; + resize: none; + overflow-y: auto; + tab-size: 2; + min-height: 600px; +} +.editor-textarea:focus { outline: none; } +.editor-textarea::placeholder { color: #475569; } + +/* 右侧栏 */ +.sidebar-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebar-section { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 12px; + padding: 16px; +} + +.sidebar-title { + font-size: 12px; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 12px; +} + +.meta-select-large { + width: 100%; + padding: 8px 12px; + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 8px; + color: #e2e8f0; + font-size: 13px; +} +.meta-select-large:focus { outline: none; border-color: #6366f1; } + +/* 标签管理 */ +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.tag-item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 14px; + font-size: 12px; + font-weight: 500; + color: #c7d2fe; +} + +.tag-remove { + background: none; border: none; color: #94a3b8; + cursor: pointer; font-size: 16px; line-height: 1; padding: 0; + width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; +} +.tag-remove:hover { color: #ef4444; } + +.tag-input { + width: 100%; + padding: 6px 10px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 6px; + color: #e2e8f0; + font-size: 12px; + margin-bottom: 10px; +} +.tag-input:focus { outline: none; border-color: #6366f1; } + +.tags-suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tag-suggestion { + padding: 3px 10px; + background: rgba(100, 116, 139, 0.15); + border: 1px solid rgba(100, 116, 139, 0.2); + border-radius: 12px; + font-size: 11px; + color: #94a3b8; + cursor: pointer; + transition: all 0.2s; +} +.tag-suggestion:hover { + background: rgba(99, 102, 241, 0.1); + border-color: rgba(99, 102, 241, 0.2); + color: #c7d2fe; +} + +/* Markdown 帮助 */ +.markdown-help { + display: flex; + flex-direction: column; + gap: 6px; +} + +.help-item { + font-size: 12px; + color: #94a3b8; + line-height: 1.5; +} + +.help-item code { + display: inline-block; + background: rgba(30, 41, 59, 0.6); + padding: 2px 6px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: #f472b6; + margin-right: 6px; + min-width: 90px; +} + +/* Toast 提示 */ +.toast { + position: fixed; bottom: 16px; right: 16px; + padding: 10px 14px; border-radius: 8px; + display: flex; align-items: center; gap: 6px; + font-size: 13px; font-weight: 500; + z-index: 10000; animation: toastSlideIn 0.3s ease-out; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); +} +@keyframes toastSlideIn { + from { opacity: 0; transform: translateX(100px); } + to { opacity: 1; transform: translateX(0); } +} +.toast svg { width: 16px; height: 16px; } +.toast-success { background: rgba(34, 197, 94, 0.15); border: 1px solid rgba(34, 197, 94, 0.3); color: #86efac; } +.toast-error { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.3); color: #fca5a5; } + +/* 响应式 */ +@media (max-width: 1024px) { + .editor-container { padding: 20px; } + .editor-workspace { grid-template-columns: 1fr; } + .sidebar-panel { + flex-direction: row; + flex-wrap: wrap; + } + .sidebar-section { flex: 1; min-width: 200px; } +} + +@media (max-width: 768px) { + .editor-page { margin-left: 0; } + .editor-container { padding: 16px; padding-bottom: 80px; } + .editor-toolbar { + flex-direction: column; + gap: 12px; + padding: 12px; + } + .toolbar-left, .toolbar-right { width: 100%; justify-content: center; } + .title-input { font-size: 18px; padding: 10px 14px; } + .editor-workspace { min-height: auto; } + .sidebar-panel { flex-direction: column; } + .editor-textarea { min-height: 400px; } +} diff --git a/website/community/assets/css/post-drawer.css b/website/community/assets/css/post-drawer.css new file mode 100644 index 0000000..32779dd --- /dev/null +++ b/website/community/assets/css/post-drawer.css @@ -0,0 +1,316 @@ +/* OSS Community - 文章抽屉样式 */ + +/* 遮罩层 */ +.post-drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 9998; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.post-drawer-overlay.active { + opacity: 1; + visibility: visible; +} + +/* 抽屉容器 */ +.post-drawer { + position: fixed; + left: 50%; + bottom: 0; + transform: translateX(-50%) translateY(100%); + width: 90%; + max-width: 900px; + max-height: 85vh; + background: #0f172a; + border: 1px solid rgba(99, 102, 241, 0.2); + border-bottom: none; + border-radius: 20px 20px 0 0; + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5); + z-index: 9999; + display: flex; + flex-direction: column; + transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +.post-drawer.active { + transform: translateX(-50%) translateY(0); +} + +/* 抽屉顶部标题栏 */ +.post-drawer-header { + flex-shrink: 0; + padding: 24px 28px 20px; + border-bottom: 1px solid rgba(99, 102, 241, 0.1); + background: rgba(15, 23, 42, 0.95); + position: relative; +} + +.post-drawer-close { + position: absolute; + top: 16px; + right: 16px; + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(30, 41, 59, 0.6); + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.post-drawer-close:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.3); + color: #fca5a5; + transform: scale(1.05); +} + +.post-drawer-close svg { + width: 18px; + height: 18px; +} + +.post-drawer-title { + font-size: 22px; + font-weight: 700; + color: #e2e8f0; + margin: 0 0 12px 0; + padding-right: 50px; + line-height: 1.3; +} + +.post-drawer-meta { + display: flex; + align-items: center; + gap: 12px; + color: #64748b; + font-size: 13px; +} + +.post-drawer-avatar { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + color: white; + flex-shrink: 0; +} + +.post-drawer-user-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.post-drawer-username { + color: #e2e8f0; + font-weight: 600; + font-size: 13px; +} + +.post-drawer-date { + color: #64748b; + font-size: 12px; +} + +.post-drawer-category { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 8px; + background: rgba(99, 102, 241, 0.2); + color: #c7d2fe; + margin-left: auto; +} + +/* 抽屉内容区域 */ +.post-drawer-body { + flex: 1; + overflow-y: auto; + padding: 28px; +} + +.post-drawer-content { + color: #cbd5e1; + font-size: 15px; + line-height: 1.8; +} + +.post-drawer-content h1, +.post-drawer-content h2, +.post-drawer-content h3 { + color: #e2e8f0; + margin: 1.2em 0 0.6em; + font-weight: 700; +} + +.post-drawer-content h1 { font-size: 1.8em; border-bottom: 2px solid rgba(99, 102, 241, 0.3); padding-bottom: 0.3em; } +.post-drawer-content h2 { font-size: 1.4em; border-bottom: 1px solid rgba(99, 102, 241, 0.2); padding-bottom: 0.2em; } +.post-drawer-content h3 { font-size: 1.2em; } + +.post-drawer-content p { + color: #cbd5e1; + line-height: 1.8; + margin: 0.8em 0; +} + +.post-drawer-content code { + background: rgba(30, 41, 59, 0.6); + padding: 2px 6px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9em; + color: #f472b6; +} + +.post-drawer-content pre { + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 8px; + padding: 16px; + overflow-x: auto; + margin: 0.8em 0; +} + +.post-drawer-content pre code { + background: transparent; + padding: 0; + color: #e2e8f0; +} + +.post-drawer-content blockquote { + border-left: 4px solid #6366f1; + margin: 0.8em 0; + padding: 12px 16px; + background: rgba(99, 102, 241, 0.05); + border-radius: 0 8px 8px 0; + color: #94a3b8; +} + +.post-drawer-content ul, +.post-drawer-content ol { + padding-left: 24px; + color: #cbd5e1; +} + +.post-drawer-content li { + margin: 0.4em 0; +} + +.post-drawer-content a { + color: #6366f1; + text-decoration: none; +} + +.post-drawer-content a:hover { + text-decoration: underline; +} + +.post-drawer-content img { + max-width: 100%; + border-radius: 8px; + margin: 0.8em 0; +} + +.post-drawer-content hr { + border: none; + height: 1px; + background: rgba(99, 102, 241, 0.2); + margin: 1.5em 0; +} + +/* 底部统计栏 */ +.post-drawer-footer { + flex-shrink: 0; + padding: 16px 28px; + border-top: 1px solid rgba(99, 102, 241, 0.1); + background: rgba(15, 23, 42, 0.95); + display: flex; + gap: 24px; + color: #64748b; + font-size: 13px; + font-weight: 500; +} + +.post-drawer-stat { + display: flex; + align-items: center; + gap: 6px; +} + +.post-drawer-stat svg { + width: 16px; + height: 16px; + opacity: 0.7; +} + +/* 滚动条样式 */ +.post-drawer-body::-webkit-scrollbar { + width: 6px; +} + +.post-drawer-body::-webkit-scrollbar-track { + background: transparent; +} + +.post-drawer-body::-webkit-scrollbar-thumb { + background: rgba(99, 102, 241, 0.3); + border-radius: 3px; +} + +.post-drawer-body::-webkit-scrollbar-thumb:hover { + background: rgba(99, 102, 241, 0.5); +} + +/* 响应式 */ +@media (max-width: 768px) { + .post-drawer { + width: 100%; + max-width: none; + max-height: 90vh; + border-radius: 16px 16px 0 0; + } + + .post-drawer-header { + padding: 20px 20px 16px; + } + + .post-drawer-title { + font-size: 18px; + } + + .post-drawer-body { + padding: 20px; + } + + .post-drawer-content { + font-size: 14px; + } + + .post-drawer-footer { + padding: 14px 20px; + gap: 16px; + } + + .post-drawer-close { + top: 12px; + right: 12px; + width: 32px; + height: 32px; + } +} diff --git a/website/community/assets/js/auth.js b/website/community/assets/js/auth.js new file mode 100644 index 0000000..727b127 --- /dev/null +++ b/website/community/assets/js/auth.js @@ -0,0 +1,168 @@ +/** + * OSS Community 认证页面 JS + * 处理登录/注册表单提交 + */ + +// 切换密码可见性 +function togglePassword(fieldId = 'password') { + const input = document.getElementById(fieldId); + const icon = document.getElementById(`eyeIcon-${fieldId}`) || document.getElementById('eyeIcon'); + + if (input.type === 'password') { + input.type = 'text'; + icon.innerHTML = ` + + `; + } else { + input.type = 'password'; + icon.innerHTML = ` + + + `; + } +} + +// 显示错误消息 +function showError(message) { + const errorEl = document.getElementById('errorMessage'); + const successEl = document.getElementById('successMessage'); + if (errorEl) { + errorEl.textContent = message; + errorEl.style.display = 'block'; + } + if (successEl) { + successEl.style.display = 'none'; + } +} + +// 显示成功消息 +function showSuccess(message) { + const successEl = document.getElementById('successMessage'); + const errorEl = document.getElementById('errorMessage'); + if (successEl) { + successEl.textContent = message; + successEl.style.display = 'block'; + } + if (errorEl) { + errorEl.style.display = 'none'; + } +} + +// 隐藏消息 +function hideMessages() { + const errorEl = document.getElementById('errorMessage'); + const successEl = document.getElementById('successMessage'); + if (errorEl) errorEl.style.display = 'none'; + if (successEl) successEl.style.display = 'none'; +} + +// 设置按钮加载状态 +function setButtonLoading(loading) { + const btn = document.getElementById('submitBtn'); + const text = btn.querySelector('.btn-text'); + const spinner = btn.querySelector('.btn-spinner'); + + if (loading) { + btn.disabled = true; + text.style.display = 'none'; + spinner.style.display = 'block'; + } else { + btn.disabled = false; + text.style.display = 'inline'; + spinner.style.display = 'none'; + } +} + +// 登录表单 +const loginForm = document.getElementById('loginForm'); +if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideMessages(); + setButtonLoading(true); + + const formData = { + username: document.getElementById('username').value.trim(), + password: document.getElementById('password').value, + remember: document.getElementById('remember').checked + }; + + try { + const response = await fetch('api/auth.php?action=login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const result = await response.json(); + + if (result.success) { + showSuccess('登录成功!正在跳转...'); + setTimeout(() => { + window.location.href = 'index.php'; + }, 1000); + } else { + showError(result.message || '登录失败,请检查用户名和密码'); + } + } catch (error) { + showError('网络错误,请稍后重试'); + } finally { + setButtonLoading(false); + } + }); +} + +// 注册表单 +const registerForm = document.getElementById('registerForm'); +if (registerForm) { + registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideMessages(); + setButtonLoading(true); + + const password = document.getElementById('password').value; + const confirmPassword = document.getElementById('confirmPassword').value; + + // 前端验证 + if (password !== confirmPassword) { + showError('两次输入的密码不一致'); + setButtonLoading(false); + return; + } + + if (password.length < 6) { + showError('密码长度至少 6 个字符'); + setButtonLoading(false); + return; + } + + const formData = { + username: document.getElementById('username').value.trim(), + email: document.getElementById('email').value.trim(), + password: password + }; + + try { + const response = await fetch('api/auth.php?action=register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const result = await response.json(); + + if (result.success) { + showSuccess('注册成功!正在跳转到登录页...'); + setTimeout(() => { + window.location.href = 'login.php'; + }, 1500); + } else { + showError(result.message || '注册失败,请稍后重试'); + } + } catch (error) { + showError('网络错误,请稍后重试'); + } finally { + setButtonLoading(false); + } + }); +} diff --git a/website/community/assets/js/community.js b/website/community/assets/js/community.js new file mode 100644 index 0000000..1fa9709 --- /dev/null +++ b/website/community/assets/js/community.js @@ -0,0 +1,255 @@ +/** + * OSS Community Module + * Handles rendering of categories, stats, and posts list. + * Designed to be SPA-friendly (re-initializable). + */ + +// Global state variables +let currentPage = 1; +let currentCategory = ''; +let currentSort = 'latest'; + +// Expose initialization function globally for SPA Router +window.initCommunity = function() { + // Check if we are deep-linked (e.g. ?page=2) + const params = new URLSearchParams(window.location.search); + if(params.has('page')) currentPage = parseInt(params.get('page')); + + // Fetch and Render Data + loadCategories(); + loadStats(); + loadPosts(); + + // Bind Events (to the new DOM elements created by SPA) + bindCommunityEvents(); +}; + +function bindCommunityEvents() { + const catList = document.getElementById('categoryList'); + if (catList) { + catList.addEventListener('click', e => { + const li = e.target.closest('li'); + if (!li) return; + + document.querySelectorAll('.category-list li').forEach(el => el.classList.remove('active')); + li.classList.add('active'); + + currentCategory = li.dataset.id || ''; + currentPage = 1; + document.getElementById('currentCategory').textContent = li.querySelector('span:nth-child(2)').textContent + '帖子'; + loadPosts(); + }); + } + + const sortOptions = document.querySelector('.sort-options'); + if (sortOptions) { + sortOptions.addEventListener('click', e => { + if (!e.target.classList.contains('sort-btn')) return; + document.querySelectorAll('.sort-btn').forEach(btn => btn.classList.remove('active')); + e.target.classList.add('active'); + currentSort = e.target.dataset.sort; + currentPage = 1; + loadPosts(); + }); + } +} + +// --- Data Loading Functions --- + +const API = './api/index.php'; // Relative to community/ directory + +async function loadCategories() { + try { + const res = await fetch(`${API}?action=categories`); + const data = await res.json(); + + const list = document.getElementById('categoryList'); + if (!list) return; + + // Keep the "All" item (first child) if it exists + const allItem = list.firstElementChild; + list.innerHTML = ''; + if (allItem) list.appendChild(allItem); + + data.categories.forEach(cat => { + const li = document.createElement('li'); + li.dataset.id = cat.id; + li.innerHTML = `${getIconSvg(cat.icon)}${cat.name}`; + list.appendChild(li); + }); + } catch (e) { + console.error('Failed to load categories', e); + } +} + +async function loadStats() { + try { + const res = await fetch(`${API}?action=stats`); + const data = await res.json(); + + animateValue('statPosts', 0, data.posts, 1000); + animateValue('statReplies', 0, data.replies, 1000); + animateValue('statUsers', 0, data.users, 1000); + const countAll = document.getElementById('countAll'); + if (countAll) countAll.textContent = data.posts; + } catch (e) { + console.error('Failed to load stats', e); + } +} + +async function loadPosts() { + const list = document.getElementById('postsList'); + if (!list) return; + + list.innerHTML = '

正在连接节点...

'; + + try { + const params = new URLSearchParams({ action: 'posts', page: currentPage }); + if (currentCategory) params.append('category_id', currentCategory); + + const res = await fetch(`${API}?${params}`); + const data = await res.json(); + + list.innerHTML = ''; + + if (data.posts.length === 0) { + list.innerHTML = ` +
+
📭
+

暂无帖子

+

成为第一个发帖的人吧!

+
+ `; + return; + } + + data.posts.forEach((post, index) => { + const card = document.createElement('div'); + card.className = 'post-card'; + card.dataset.postId = post.id; + card.style.animationDelay = `${index * 0.1}s`; + + card.innerHTML = ` +
+
${post.username[0].toUpperCase()}
+
+
${escapeHtml(post.username)}
+
${timeAgo(post.created_at)}
+
+
+ ${post.category_name} + ${post.is_pinned ? ' 置顶' : ''} +
+
+
${escapeHtml(post.title)}
+
${escapeHtml(post.content.substring(0, 150))}...
+
+ + + ${post.views} + + + + ${post.likes} + + + + ${post.reply_count || 0} + +
+ `; + list.appendChild(card); + }); + + renderPagination(data.pages); + } catch (e) { + list.innerHTML = '

加载失败,请稍后重试

'; + } +} + +function renderPagination(pages) { + const container = document.getElementById('pagination'); + if (!container) return; + + container.innerHTML = ''; + if (pages <= 1) return; + + for (let i = 1; i <= pages; i++) { + const btn = document.createElement('button'); + btn.className = `page-btn ${i === currentPage ? 'active' : ''}`; + btn.textContent = i; + btn.onclick = () => { + currentPage = i; + loadPosts(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + container.appendChild(btn); + } +} + +// --- Utilities --- + +function animateValue(id, start, end, duration) { + const obj = document.getElementById(id); + if (!obj) return; + let startTimestamp = null; + const step = (timestamp) => { + if (!startTimestamp) startTimestamp = timestamp; + const progress = Math.min((timestamp - startTimestamp) / duration, 1); + obj.textContent = Math.floor(progress * (end - start) + start); + if (progress < 1) { + window.requestAnimationFrame(step); + } + }; + window.requestAnimationFrame(step); +} + +function timeAgo(dateStr) { + const now = new Date(); + const date = new Date(dateStr); + const seconds = Math.floor((now - date) / 1000); + const intervals = [ + [31536000, '年'], [2592000, '个月'], [604800, '周'], + [86400, '天'], [3600, '小时'], [60, '分钟'] + ]; + for (const [secondsCount, label] of intervals) { + const count = Math.floor(seconds / secondsCount); + if (count >= 1) return `${count}${label}前`; + } + return '刚刚'; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function getIconSvg(name) { + const icons = { + megaphone: '', + question: '', + chat: '', + puzzle: '', + bug: '' + }; + return icons[name] || icons['chat']; +} + +// showCreateModal 函数已移至 post-modal.php 中定义 +// 此处仅保留兼容性封装 +window.showCreateModal = window.showCreateModal || function() { + if (typeof window.showCreatePostModal === 'function') { + window.showCreatePostModal(); + } else { + console.warn('发帖模态框未加载'); + } +}; + +// Auto-init if not loaded via SPA (Hard refresh case) +document.addEventListener('DOMContentLoaded', () => { + if (typeof AppRouter === 'undefined') { + window.initCommunity(); + } +}); \ No newline at end of file diff --git a/website/community/assets/js/dock-popover.js b/website/community/assets/js/dock-popover.js new file mode 100644 index 0000000..4d4940e --- /dev/null +++ b/website/community/assets/js/dock-popover.js @@ -0,0 +1,91 @@ +document.addEventListener('DOMContentLoaded', () => { + const trigger = document.getElementById('dockUserMenuBtn'); + const popover = document.getElementById('dockUserMenu'); + const logoutBtn = document.getElementById('logoutBtn'); + const deleteBtn = document.getElementById('deleteAccountBtn'); + const myPostCount = document.getElementById('myPostCount'); + + // 获取用户文章数量 + async function fetchMyPostCount() { + if (!myPostCount) return; + try { + const res = await fetch('api/auth.php?action=my-post-count'); + if (res.ok) { + const data = await res.json(); + if (data.success) { + myPostCount.textContent = data.count; + } + } + } catch (err) { + console.error('Failed to fetch post count:', err); + } + } + + // 页面加载时获取文章数量 + fetchMyPostCount(); + + if (trigger && popover) { + // 点击图标切换面板 + trigger.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const isActive = popover.classList.contains('active'); + if (isActive) { + popover.classList.remove('active'); + } else { + // 计算位置:在图标右侧 + const rect = trigger.getBoundingClientRect(); + // Dock 通常在左侧,我们定位在图标右边,稍微向上偏移一点居中 + popover.style.left = `${rect.right + 16}px`; + popover.style.top = `${rect.top + 10}px`; + popover.classList.add('active'); + } + }); + + // 点击外部关闭 + document.addEventListener('click', (e) => { + if (!popover.contains(e.target) && !trigger.contains(e.target)) { + popover.classList.remove('active'); + } + }); + + // 退出登录逻辑 + if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + try { + const originalText = logoutBtn.innerHTML; + logoutBtn.innerHTML = ' 退出中...'; + logoutBtn.disabled = true; + + // 使用相对路径,因为 JS 可能在子目录中运行 + // 注意:这里假设 api/auth.php 相对于当前页面路径可用 + // 在 community/index.php 中,api/ 是同级目录 + const res = await fetch('api/auth.php?action=logout'); + + if (res.ok) { + // 退出成功,刷新页面 + window.location.reload(); + } else { + logoutBtn.innerHTML = originalText; + logoutBtn.disabled = false; + alert('退出失败,请重试'); + } + } catch (err) { + console.error('Logout error:', err); + logoutBtn.innerHTML = '退出失败'; + logoutBtn.disabled = false; + } + }); + } + + // 注销账户逻辑 + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + if (confirm('确定要注销(永久删除)此账户吗?\n此操作不可撤销,所有数据将被清除。')) { + alert('该功能暂未开放,请联系管理员。'); + } + }); + } + } +}); diff --git a/website/community/assets/js/editor.js b/website/community/assets/js/editor.js new file mode 100644 index 0000000..3bc90d3 --- /dev/null +++ b/website/community/assets/js/editor.js @@ -0,0 +1,252 @@ +/** + * OSS Community Editor JS + * Markdown 编辑器、实时预览、表单提交 + */ + +document.addEventListener('DOMContentLoaded', () => { + initEditor(); + initToolbar(); + initTags(); + initForm(); +}); + +// 初始化编辑器 +function initEditor() { + const textarea = document.getElementById('postContent'); + const titleInput = document.getElementById('postTitle'); + + // Tab 键支持 + if (textarea) { + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + 2; + } + }); + } +} + +// 初始化工具栏 +function initToolbar() { + const buttons = document.querySelectorAll('.md-btn'); + const textarea = document.getElementById('postContent'); + + if (!textarea) return; + + buttons.forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.md; + insertMarkdown(textarea, action); + }); + }); +} + +function insertMarkdown(textarea, action) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selected = textarea.value.substring(start, end); + let insertion = ''; + + switch (action) { + case 'bold': + insertion = `**${selected || '粗体文本'}**`; + break; + case 'italic': + insertion = `*${selected || '斜体文本'}*`; + break; + case 'heading': + insertion = `\n## ${selected || '标题'}\n`; + break; + case 'quote': + insertion = `\n> ${selected || '引用文本'}\n`; + break; + case 'code': + insertion = selected.includes('\n') ? `\n\`\`\`\n${selected || '代码块'}\n\`\`\`\n` : `\`${selected || '行内代码'}\``; + break; + case 'link': + insertion = `[${selected || '链接文本'}](url)`; + break; + case 'list': + insertion = `\n- ${selected || '列表项'}\n`; + break; + } + + textarea.value = textarea.value.substring(0, start) + insertion + textarea.value.substring(end); + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = start + insertion.length; +} + +// 初始化标签 +function initTags() { + const tagInput = document.getElementById('tagInput'); + const tagsContainer = document.getElementById('tagsContainer'); + + if (!tagInput || !tagsContainer) return; + + tagInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const tagName = tagInput.value.trim(); + if (tagName) { + addTag(tagName); + tagInput.value = ''; + } + } + }); +} + +function addTag(name) { + const tagsContainer = document.getElementById('tagsContainer'); + if (!tagsContainer) return; + + // 检查是否已存在 + const existing = tagsContainer.querySelectorAll('.tag-item'); + for (const tag of existing) { + if (tag.textContent.trim().replace('×', '').trim() === name) { + return; + } + } + + const tagEl = document.createElement('span'); + tagEl.className = 'tag-item'; + tagEl.innerHTML = ` + ${name} + + `; + tagsContainer.appendChild(tagEl); +} + +// 初始化表单 +function initForm() { + const form = document.getElementById('postEditorForm'); + const saveBtn = document.getElementById('savePostBtn'); + + if (!form || !saveBtn) return; + + saveBtn.addEventListener('click', async (e) => { + e.preventDefault(); + + const postId = document.getElementById('editPostId').value; + const title = document.getElementById('postTitle').value.trim(); + const content = document.getElementById('postContent').value.trim(); + const categoryId = document.getElementById('postCategory').value; + + // 收集标签 + const tags = []; + document.querySelectorAll('#tagsContainer .tag-item').forEach(tag => { + const name = tag.textContent.replace('×', '').trim(); + if (name) tags.push(name); + }); + + // 验证 + if (!title) { + showError('请输入帖子标题'); + return; + } + if (title.length < 5) { + showError('标题至少 5 个字符'); + return; + } + if (!content) { + showError('请输入帖子内容'); + return; + } + if (content.length < 10) { + showError('内容至少 10 个字符'); + return; + } + if (!categoryId) { + showError('请选择分类'); + return; + } + + // 提交 + setSaveButtonLoading(true); + + const formData = { + title: title, + content: content, + category_id: categoryId, + tags: tags + }; + + if (postId) { + formData.id = parseInt(postId); + } + + const action = postId ? 'update' : 'create'; + const url = `api/posts.php?action=${action}`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const result = await response.json(); + + if (result.success) { + showSuccess(postId ? '更新成功!' : '发布成功!正在跳转...'); + setTimeout(() => { + window.location.href = `post.php?id=${result.post_id || postId}`; + }, 1000); + } else { + showError(result.message || '操作失败'); + } + } catch (error) { + showError('网络错误,请稍后重试'); + } finally { + setSaveButtonLoading(false); + } + }); +} + +function showSuccess(message) { + const toast = document.getElementById('successToast'); + const msgEl = document.getElementById('successMessage'); + if (toast && msgEl) { + msgEl.textContent = message; + toast.style.display = 'flex'; + setTimeout(() => { toast.style.display = 'none'; }, 3000); + } +} + +function showError(message) { + const toast = document.getElementById('errorToast'); + const msgEl = document.getElementById('errorMessage'); + if (toast && msgEl) { + msgEl.textContent = message; + toast.style.display = 'flex'; + setTimeout(() => { toast.style.display = 'none'; }, 4000); + } +} + +function setSaveButtonLoading(loading) { + const btn = document.getElementById('savePostBtn'); + if (!btn) return; + + if (loading) { + btn.disabled = true; + btn.style.opacity = '0.6'; + btn.innerHTML = ` + + + + + 处理中... + `; + } else { + btn.disabled = false; + btn.style.opacity = '1'; + btn.innerHTML = ` + + + + ${document.getElementById('editPostId').value ? '保存修改' : '发布帖子'} + `; + } +} diff --git a/website/community/assets/js/polling-system.js b/website/community/assets/js/polling-system.js new file mode 100644 index 0000000..2f93251 --- /dev/null +++ b/website/community/assets/js/polling-system.js @@ -0,0 +1,91 @@ +/** + * OSS Community - 实时轮询系统 + * 每2秒从数据库刷新所有数据 + */ + +class CommunityPollingSystem { + constructor() { + this.interval = 2000; + this.timer = null; + this.isRunning = false; + this.listeners = { user: [], posts: [], stats: [], categories: [] }; + this.cache = { user: null, posts: null, stats: null, categories: null }; + } + + start() { + if (this.isRunning) return; + this.isRunning = true; + this._fetchAll(); + this.timer = setInterval(() => this._fetchAll(), this.interval); + } + + stop() { + clearInterval(this.timer); + this.isRunning = false; + this.timer = null; + } + + on(event, callback) { + if (this.listeners[event]) this.listeners[event].push(callback); + } + + async _fetch(url) { + try { + const res = await fetch(url); + return res.ok ? await res.json() : null; + } catch { return null; } + } + + async _fetchAll() { + // 并行请求所有接口 + const [userData, postsData, statsData, catsData] = await Promise.all([ + this._fetch('api/auth.php?action=current-user'), + this._fetch('api/index.php?action=posts'), + this._fetch('api/index.php?action=stats'), + this._fetch('api/index.php?action=categories') + ]); + + // 用户数据 + if (userData && userData.success) { + const changed = JSON.stringify(userData) !== JSON.stringify(this.cache.user); + this.cache.user = userData; + if (changed) this._notify('user', userData); + } + + // 帖子数据(含 views, likes, replies) + if (postsData && postsData.posts) { + const changed = JSON.stringify(postsData.posts) !== JSON.stringify(this.cache.posts); + this.cache.posts = postsData.posts; + if (changed) this._notify('posts', postsData); + } + + // 统计数据(含 hot_posts) + if (statsData) { + const changed = JSON.stringify(statsData) !== JSON.stringify(this.cache.stats); + this.cache.stats = statsData; + if (changed) this._notify('stats', statsData); + } + + // 分类 + if (catsData && catsData.categories) { + const changed = JSON.stringify(catsData.categories) !== JSON.stringify(this.cache.categories); + this.cache.categories = catsData.categories; + if (changed) this._notify('categories', catsData); + } + } + + _notify(event, data) { + (this.listeners[event] || []).forEach(fn => { + try { fn(data); } catch(e) {} + }); + } +} + +window.Polling = new CommunityPollingSystem(); + +document.addEventListener('DOMContentLoaded', () => { + const dock = document.getElementById('dock'); + if (dock && dock.dataset.loggedIn === '1') { + window.Polling.start(); + } +}); diff --git a/website/community/assets/js/post-drawer.js b/website/community/assets/js/post-drawer.js new file mode 100644 index 0000000..3e181f6 --- /dev/null +++ b/website/community/assets/js/post-drawer.js @@ -0,0 +1,236 @@ +/** + * OSS Community - 文章抽屉交互逻辑 + */ +document.addEventListener('DOMContentLoaded', () => { + initPostDrawer(); +}); + +let drawerInitialized = false; +let currentDrawerPostId = null; +let drawerPollTimer = null; + +function initPostDrawer() { + if (drawerInitialized) return; + drawerInitialized = true; + + createDrawerElements(); + + // 全局点击事件委托 + document.addEventListener('click', (e) => { + const postCard = e.target.closest('.post-card'); + if (postCard && postCard.dataset.postId) { + e.preventDefault(); + e.stopPropagation(); + openPostDrawer(postCard.dataset.postId); + } + }); + + const overlay = document.getElementById('postDrawerOverlay'); + if (overlay) overlay.addEventListener('click', closePostDrawer); + + const closeBtn = document.getElementById('postDrawerClose'); + if (closeBtn) closeBtn.addEventListener('click', closePostDrawer); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closePostDrawer(); + }); + + // 监听轮询数据更新抽屉内的 views/likes + if (window.Polling) { + window.Polling.on('posts', (data) => { + if (!data.posts || !currentDrawerPostId) return; + const post = data.posts.find(p => p.id == currentDrawerPostId); + if (post) updateDrawerStats(post); + }); + } +} + +function createDrawerElements() { + if (document.getElementById('postDrawerOverlay')) return; + + // 创建遮罩层 + const overlay = document.createElement('div'); + overlay.id = 'postDrawerOverlay'; + overlay.className = 'post-drawer-overlay'; + + // 创建抽屉 + const drawer = document.createElement('div'); + drawer.id = 'postDrawer'; + drawer.className = 'post-drawer'; + drawer.innerHTML = ` +
+ +

+
+
+
+
+
+
+ `; + + document.body.appendChild(overlay); + document.body.appendChild(drawer); + + // 绑定关闭按钮 + document.getElementById('postDrawerClose').addEventListener('click', closePostDrawer); +} + +async function openPostDrawer(postId) { + const drawer = document.getElementById('postDrawer'); + const overlay = document.getElementById('postDrawerOverlay'); + const titleEl = document.getElementById('postDrawerTitle'); + const metaEl = document.getElementById('postDrawerMeta'); + const contentEl = document.getElementById('postDrawerContent'); + const footerEl = document.getElementById('postDrawerFooter'); + + if (!drawer || !overlay) return; + + // 显示加载状态 + titleEl.textContent = '加载中...'; + metaEl.innerHTML = ''; + contentEl.innerHTML = '
正在加载文章内容...
'; + footerEl.innerHTML = ''; + + // 显示抽屉(先滑入,再加载内容) + overlay.classList.add('active'); + drawer.classList.add('active'); + document.body.style.overflow = 'hidden'; + + try { + const response = await fetch(`api/index.php?action=post&id=${postId}`); + if (!response.ok) throw new Error('加载失败'); + + const data = await response.json(); + if (!data.post) throw new Error('帖子不存在'); + + const post = data.post; + const replies = data.replies || []; + + currentDrawerPostId = post.id; + + // 更新标题和元信息 + titleEl.textContent = post.title; + metaEl.innerHTML = ` +
${post.username.charAt(0).toUpperCase()}
+
+
${escapeHtml(post.username)}
+
${formatDate(post.created_at)}
+
+ ${escapeHtml(post.category_name)} + `; + + // 更新内容(使用 marked 解析 Markdown) + if (typeof marked !== 'undefined') { + contentEl.innerHTML = marked.parse(post.content); + } else { + contentEl.innerHTML = escapeHtml(post.content).replace(/\n/g, '
'); + } + + // 更新底部统计 + footerEl.innerHTML = ` +
+ + ${post.views} 浏览 +
+
+ + ${post.likes} 点赞 +
+
+ + ${replies.length} 回复 +
+ `; + + // 如果有回复,在内容下方显示 + if (replies.length > 0) { + contentEl.innerHTML += ` +
+

回复 (${replies.length})

+ ${replies.map(reply => ` +
+
+
+ ${reply.username.charAt(0).toUpperCase()} +
+ ${escapeHtml(reply.username)} + ${formatDate(reply.created_at)} +
+
${escapeHtml(reply.content).replace(/\n/g, '
')}
+
+ `).join('')} + `; + } + + } catch (error) { + console.error('Failed to load post:', error); + contentEl.innerHTML = ` +
+ + + + + +

加载失败

+

${error.message || '未知错误,请稍后重试'}

+
+ `; + } +} + +function closePostDrawer() { + const drawer = document.getElementById('postDrawer'); + const overlay = document.getElementById('postDrawerOverlay'); + + if (drawer) drawer.classList.remove('active'); + if (overlay) overlay.classList.remove('active'); + document.body.style.overflow = ''; + currentDrawerPostId = null; +} + +function updateDrawerStats(post) { + const footerEl = document.getElementById('postDrawerFooter'); + if (!footerEl || !currentDrawerPostId) return; + + footerEl.innerHTML = ` +
+ + ${post.views} 浏览 +
+
+ + ${post.likes} 点赞 +
+
+ + ${post.reply_count || 0} 回复 +
+ `; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return '刚刚'; + if (minutes < 60) return `${minutes} 分钟前`; + if (hours < 24) return `${hours} 小时前`; + if (days < 7) return `${days} 天前`; + return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); +} diff --git a/website/community/assets/js/title-updater.js b/website/community/assets/js/title-updater.js new file mode 100644 index 0000000..ab4e0ae --- /dev/null +++ b/website/community/assets/js/title-updater.js @@ -0,0 +1,108 @@ +/** + * OSS Community - 轮询数据实时更新 + */ + +document.addEventListener('DOMContentLoaded', () => { + if (!window.Polling) return; + + // ========== 用户数据(称号、权限) ========== + let prevTitle = '', prevPerms = {}; + window.Polling.on('user', (data) => { + if (!data.success || !data.user) return; + const { title, role } = data.user; + + // 更新称号徽章 + if (title !== prevTitle) { + prevTitle = title; + const avatar = document.getElementById('dockUserAvatar'); + if (avatar) { + const name = avatar.dataset.tooltip.split(' - ')[0] || avatar.dataset.tooltip; + avatar.dataset.tooltip = title ? `${name} - ${title}` : name; + } + let badge = document.querySelector('.user-title-badge'); + if (title) { + if (!badge) { badge = document.createElement('span'); badge.className = 'user-title-badge'; if (avatar) avatar.appendChild(badge); } + badge.textContent = title; + } else if (badge) badge.remove(); + } + + // 权限控制 + if (data.permissions && JSON.stringify(data.permissions) !== JSON.stringify(prevPerms)) { + prevPerms = data.permissions; + document.querySelectorAll('.admin-only').forEach(el => el.style.display = prevPerms.can_manage_users ? '' : 'none'); + document.querySelectorAll('.moderator-only').forEach(el => el.style.display = prevPerms.can_manage_posts ? '' : 'none'); + } + }); + + // ========== 帖子数据实时更新 views/likes/replies ========== + window.Polling.on('posts', (data) => { + if (!data.posts || !Array.isArray(data.posts)) return; + + data.posts.forEach(post => { + // 更新帖子卡片上的浏览、点赞、回复数 + const cards = document.querySelectorAll(`.post-card[data-post-id="${post.id}"]`); + cards.forEach(card => { + const statsEls = card.querySelectorAll('.post-stats span'); + if (statsEls.length >= 3) { + // views + const viewsSvg = ''; + // likes + const likesSvg = ''; + // replies + const repliesSvg = ''; + + statsEls[0].innerHTML = viewsSvg + ` ${post.views}`; + statsEls[1].innerHTML = likesSvg + ` ${post.likes}`; + statsEls[2].innerHTML = repliesSvg + ` ${post.reply_count || 0}`; + } + }); + + // 更新个人主页帖子列表的 stats + const myListItems = document.querySelectorAll(`.my-post-item[data-post-id="${post.id}"] .my-post-meta`); + myListItems.forEach(meta => { + const spans = meta.querySelectorAll('span'); + if (spans.length >= 4) { + spans[1].textContent = `👁️ ${post.views} 浏览`; + spans[2].textContent = `❤️ ${post.likes} 点赞`; + spans[3].textContent = `💬 ${post.reply_count || 0} 回复`; + } + }); + }); + }); + + // ========== 统计数字实时更新 ========== + window.Polling.on('stats', (data) => { + if (data.posts !== undefined) animateNumber('statPosts', data.posts); + if (data.replies !== undefined) animateNumber('statReplies', data.replies); + if (data.users !== undefined) animateNumber('statUsers', data.users); + const countAll = document.getElementById('countAll'); + if (countAll) countAll.textContent = data.posts || 0; + + // 更新热门帖子 views(如果有这个区域) + if (data.hot_posts && Array.isArray(data.hot_posts)) { + data.hot_posts.forEach(hp => { + const hotItems = document.querySelectorAll(`.hot-post-item[data-post-id="${hp.id}"]`); + hotItems.forEach(item => { + const viewsEl = item.querySelector('.hot-views'); + if (viewsEl) viewsEl.textContent = hp.views; + }); + }); + } + }); + + // ========== 分类更新 ========== + window.Polling.on('categories', (data) => { + if (!data.categories) return; + const countAll = document.getElementById('countAll'); + if (countAll) countAll.textContent = data.posts || data.categories.length; + }); +}); + +// 数字动画 +function animateNumber(id, target) { + const el = document.getElementById(id); + if (!el) return; + const current = parseInt(el.textContent) || 0; + if (current === target) return; + el.textContent = target; +} diff --git a/website/community/edit-profile.php b/website/community/edit-profile.php new file mode 100644 index 0000000..db2df30 --- /dev/null +++ b/website/community/edit-profile.php @@ -0,0 +1,388 @@ +fetchOne( + "SELECT id, username, email, bio FROM users WHERE id = ?", + [$userId] +); + +$success = $_GET['success'] ?? ''; +$error = $_GET['error'] ?? ''; + +// 处理表单提交 +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $bio = trim($_POST['bio'] ?? ''); + + try { + $db->query( + "UPDATE users SET bio = ? WHERE id = ?", + [$bio, $userId] + ); + header('Location: edit-profile.php?success=1'); + exit; + } catch (Exception $e) { + $error = '保存失败:' . $e->getMessage(); + } +} +?> + + + + + + 编辑资料 - OSS Community + + + + + + + + + + + + +
+
+

编辑个人资料

+

更新您的个人简介和公开信息

+
+ + +
+ ✓ 资料已成功更新 +
+ + + +
+ ✗ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + 取消 + + +
+
+ +
+

预览

+
+
+
+ +
+
+
+
+
+
+
+ 暂无简介' ?> +
+
+
+
+ + + + + + diff --git a/website/community/editor.php b/website/community/editor.php new file mode 100644 index 0000000..0518e4f --- /dev/null +++ b/website/community/editor.php @@ -0,0 +1,156 @@ + 0; +$postData = null; + +if ($isEdit) { + $db = Database::getInstance(); + $post = $db->fetchOne("SELECT * FROM posts WHERE id = ? AND user_id = ?", [$postId, $_SESSION['user_id']]); + if (!$post) { http_response_code(404); die('帖子不存在或无权编辑'); } + $postData = $post; +} + +$db = Database::getInstance(); +$categories = $db->fetchAll("SELECT * FROM categories ORDER BY sort_order ASC"); +$allTags = $db->fetchAll("SELECT * FROM tags ORDER BY name ASC"); +$postTags = []; +if ($postData) { + $postTags = $db->fetchAll("SELECT t.* FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ?", [$postId]); +} +?> + + + + + + <?php echo $isEdit ? '编辑帖子' : '发布新帖'; ?> - OSS Community + + + + + + + + + + + +
+
+ +
+
+ + + 返回 + +

+
+
+ +
+
+ +
+ + + +
+ +
+ + +
+ +
+
+ Markdown 编辑器 +
+ + + + + + + +
+
+ +
+ + +
+
+
分类
+ +
+ +
+
标签
+ +
+ + + + + + +
+
+ + + +
+
+ +
+
Markdown 语法
+
+
# 标题 一级标题
+
## 标题 二级标题
+
**粗体** 粗体文字
+
*斜体* 斜体文字
+
`代码` 行内代码
+
> 引用 引用块
+
- 列表 无序列表
+
[链接](url) 超链接
+
+
+
+
+
+
+
+ +
+ + +
+
+ + +
+ + + + + diff --git a/website/community/includes/Database.php b/website/community/includes/Database.php new file mode 100644 index 0000000..2c79c24 --- /dev/null +++ b/website/community/includes/Database.php @@ -0,0 +1,47 @@ +pdo = new PDO($dsn, $config['username'], $config['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + + public static function getInstance() { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function query($sql, $params = []) { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public function fetchAll($sql, $params = []) { + return $this->query($sql, $params)->fetchAll(); + } + + public function fetchOne($sql, $params = []) { + return $this->query($sql, $params)->fetch(); + } + + public function lastInsertId() { + return $this->pdo->lastInsertId(); + } +} diff --git a/website/community/includes/dock.php b/website/community/includes/dock.php new file mode 100644 index 0000000..16acbd3 --- /dev/null +++ b/website/community/includes/dock.php @@ -0,0 +1,75 @@ +fetchOne("SELECT role, title FROM users WHERE id = ?", [$_SESSION['user_id']]); + if ($userData) { + $role = isset($userData['role']) ? ucfirst($userData['role']) : 'Member'; + $userTitle = $userData['title'] ?? ''; + // 更新 session 中的 role + $_SESSION['role'] = $userData['role'] ?? 'member'; + } + } catch (Exception $e) { + // 忽略错误,继续执行 + } +} + +// 构建 tooltip 文本 +$tooltipText = $userTitle ? "{$username} - {$userTitle}" : $username; +?> +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/website/community/includes/post-modal.php b/website/community/includes/post-modal.php new file mode 100644 index 0000000..6ebfca1 --- /dev/null +++ b/website/community/includes/post-modal.php @@ -0,0 +1,478 @@ + +
+
+
+
+

发布新帖

+ +
+ +
+ + +
+ + + 0/200 +
+ +
+ + +
+ +
+ + + 0 个字符 +
+ +
+ + + 例如:Go, 插件, 安装 +
+ +
+
+ +
+ + +
+
+
+
+ + + + diff --git a/website/community/index.php b/website/community/index.php new file mode 100644 index 0000000..18eed3f --- /dev/null +++ b/website/community/index.php @@ -0,0 +1,91 @@ + + + + + + OSS Community - 开发者社区 + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+

社区

+
+
+

分类

+
    +
  • + + 全部 + +
  • +
+
+ +
+

统计

+
+
+ 0 + 帖子 +
+
+ 0 + 回复 +
+
+ 0 + 用户 +
+
+
+
+ + +
+
+

全部帖子

+
+ + + +
+
+ +
+ +
+ +
+
+
+
+ + + + + + + + + diff --git a/website/community/install.sh b/website/community/install.sh new file mode 100644 index 0000000..93af858 --- /dev/null +++ b/website/community/install.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# OSS Community 安装脚本 + +set -e + +echo "========================================" +echo " OSS Community 安装向导" +echo "========================================" +echo "" + +# 1. 检测 PHP +echo -ne "[1/4] 检测 PHP 环境..." +if command -v php &> /dev/null; then + echo -e " \033[0;32m已安装 ($(php -v | head -n1 | awk '{print $2}'))\033[0m" +else + echo -e " \033[1;33m未安装 PHP\033[0m" + echo "请运行: sudo apt install php php-mysql php-pdo php-json" + exit 1 +fi + +# 2. 检测 MySQL +echo -ne "[2/4] 检测 MySQL 环境..." +if command -v mysql &> /dev/null; then + echo -e " \033[0;32m已安装\033[0m" +else + echo -e " \033[1;33m未安装 MySQL\033[0m" + echo "请运行: sudo apt install mysql-server" + exit 1 +fi + +# 3. 数据库配置 +echo "" +echo "请输入 MySQL 配置:" +read -p " 数据库主机 [127.0.0.1]: " DB_HOST +DB_HOST=${DB_HOST:-127.0.0.1} +read -p " 数据库端口 [3306]: " DB_PORT +DB_PORT=${DB_PORT:-3306} +read -p " 数据库用户名 [root]: " DB_USER +DB_USER=${DB_USER:-root} +read -sp " 数据库密码: " DB_PASS +echo "" +read -p " 数据库名 [oss_community]: " DB_NAME +DB_NAME=${DB_NAME:-oss_community} + +# 写入配置 +cat > config.php << EOF + '$DB_HOST', + 'port' => '$DB_PORT', + 'dbname' => '$DB_NAME', + 'username' => '$DB_USER', + 'password' => '$DB_PASS', + 'charset' => 'utf8mb4', +]; +EOF + +# 4. 导入数据库 +echo -ne "[3/4] 导入数据库结构..." +if mysql -u "$DB_USER" -p"$DB_PASS" -h "$DB_HOST" -P "$DB_PORT" < schema.sql 2>/dev/null; then + echo -e " \033[0;32m导入成功\033[0m" +else + echo -e " \033[0;31m导入失败,请检查 MySQL 连接信息\033[0m" + exit 1 +fi + +# 5. 启动 PHP 内置服务器 +echo -ne "[4/4] 启动社区服务器..." +echo -e " \033[0;32m完成\033[0m" + +echo "" +echo "========================================" +echo " 安装完成!" +echo "========================================" +echo "" +echo "访问 http://localhost:8081/community/ 查看社区" +echo "" +echo "启动命令: php -S localhost:8081 -t ../" +echo "" diff --git a/website/community/login.php b/website/community/login.php new file mode 100644 index 0000000..ca10518 --- /dev/null +++ b/website/community/login.php @@ -0,0 +1,110 @@ + + + + + + 登录 - OSS Community + + + + + + + + + + + +
+
+
+
+
+ + + +
+

欢迎回来

+

登录到你的 OSS Community 账户

+
+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + + +
+
+ +
+ + 忘记密码? +
+ +
+
+ + +
+ +
+

还没有账户?立即注册

+
+
+ +
+
+
+
+
+
+
+ + + + + diff --git a/website/community/migrate-add-bio.php b/website/community/migrate-add-bio.php new file mode 100644 index 0000000..aad0f7d --- /dev/null +++ b/website/community/migrate-add-bio.php @@ -0,0 +1,29 @@ +fetchAll("SHOW COLUMNS FROM users LIKE 'bio'"); + + if (empty($columns)) { + // 字段不存在,添加 + $db->query("ALTER TABLE users ADD COLUMN bio TEXT AFTER avatar"); + echo "✓ 成功添加 bio 字段\n"; + } else { + echo "✓ bio 字段已存在,无需迁移\n"; + } + + echo "迁移完成!\n"; +} catch (Exception $e) { + echo "✗ 迁移失败:" . $e->getMessage() . "\n"; + exit(1); +} diff --git a/website/community/my-posts.php b/website/community/my-posts.php new file mode 100644 index 0000000..4d79188 --- /dev/null +++ b/website/community/my-posts.php @@ -0,0 +1,498 @@ +fetchOne("SELECT id, username FROM users WHERE id = ?", [$viewUserId]); + +if (!$user) { + header('HTTP/1.0 404 Not Found'); + exit('用户不存在'); +} + +$isCurrentUser = ($currentUserId == $viewUserId); + +// 获取用户文章 +$page = max(1, (int)($_GET['page'] ?? 1)); +$limit = 20; +$offset = ($page - 1) * $limit; + +$posts = $db->fetchAll( + "SELECT p.*, c.name as category_name, c.slug as category_slug, + (SELECT COUNT(*) FROM replies r WHERE r.post_id = p.id) as reply_count + FROM posts p + JOIN categories c ON p.category_id = c.id + WHERE p.user_id = ? + ORDER BY p.is_pinned DESC, p.created_at DESC + LIMIT ? OFFSET ?", + [$viewUserId, $limit, $offset] +); + +$total = $db->fetchOne("SELECT COUNT(*) as count FROM posts WHERE user_id = ?", [$viewUserId])['count']; +$pages = ceil($total / $limit); +?> + + + + + + <?= htmlspecialchars($user['username']) ?> 的文章 - OSS Community + + + + + + + + + + + + + +
+
+

+

+
+ +
+
+
+
文章总数
+
+
+
+
总浏览量
+
+
+
+
总点赞数
+
+
+ +
+ +
+ + + +

还没有发表文章

+

开始创作您的第一篇文章吧!

+ + + + + 发表文章 + +
+ + $post): ?> +
+
+ +
+ + 📌 置顶 + + + ✓ 已解决 + + +
+
+
...
+
+ 📅 + 👁️ 浏览 + ❤️ 点赞 + 💬 回复 +
+
+ + + + + + 编辑 + + + +
+
+ + + 1): ?> +
+ + + + + +
+ + +
+
+ + + + + + diff --git a/website/community/post.php b/website/community/post.php new file mode 100644 index 0000000..e75090d --- /dev/null +++ b/website/community/post.php @@ -0,0 +1,117 @@ +fetchOne( + "SELECT p.*, u.username, u.avatar, u.role, c.name as category_name + FROM posts p JOIN users u ON p.user_id = u.id + JOIN categories c ON p.category_id = c.id WHERE p.id = ?", + [$id] +); + +if (!$post) { header('HTTP/1.0 404 Not Found'); exit('帖子不存在'); } + +$db->query("UPDATE posts SET views = views + 1 WHERE id = ?", [$id]); +$replies = $db->fetchAll( + "SELECT r.*, u.username, u.avatar FROM replies r JOIN users u ON r.user_id = u.id WHERE r.post_id = ? ORDER BY r.created_at ASC", + [$id] +); +?> + + + + + + <?= htmlspecialchars($post['title']) ?> - OSS Community + + + + + + + + + + + + + + + +
+ + + 返回列表 + +
+ +
+
+
+

+
+
+
+
+
·
+
+
+
+
+
+ 👁️ 浏览 + ❤️ 点赞 + 💬 回复 +
+
+ +
+

回复 ()

+ +
暂无回复,抢沙发吧!
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + diff --git a/website/community/profile.php b/website/community/profile.php new file mode 100644 index 0000000..65be39d --- /dev/null +++ b/website/community/profile.php @@ -0,0 +1,717 @@ +fetchOne( + "SELECT id, username, email, avatar, role, bio, created_at FROM users WHERE id = ?", + [$viewUserId] +); + +if (!$user) { + header('HTTP/1.0 404 Not Found'); + exit('用户不存在'); +} + +// 获取用户统计数据 +$stats = $db->fetchOne( + "SELECT + (SELECT COUNT(*) FROM posts WHERE user_id = ?) as post_count, + (SELECT COUNT(*) FROM replies WHERE user_id = ?) as reply_count, + (SELECT SUM(views) FROM posts WHERE user_id = ?) as total_views, + (SELECT SUM(likes) FROM posts WHERE user_id = ?) as total_likes", + [$viewUserId, $viewUserId, $viewUserId, $viewUserId] +); + +// 获取用户的文章(最近 10 篇) +$posts = $db->fetchAll( + "SELECT p.*, c.name as category_name, c.slug as category_slug, + (SELECT COUNT(*) FROM replies r WHERE r.post_id = p.id) as reply_count + FROM posts p + JOIN categories c ON p.category_id = c.id + WHERE p.user_id = ? + ORDER BY p.created_at DESC + LIMIT 10", + [$viewUserId] +); + +// 获取用户的最近回复 +$replies = $db->fetchAll( + "SELECT r.*, p.title as post_title, p.id as post_id + FROM replies r + JOIN posts p ON r.post_id = p.id + WHERE r.user_id = ? + ORDER BY r.created_at DESC + LIMIT 5", + [$viewUserId] +); + +$isCurrentUser = ($currentUserId == $viewUserId); +$isAdminOrMod = in_array($_SESSION['role'] ?? '', ['admin', 'moderator']); +?> + + + + + + <?= htmlspecialchars($user['username']) ?> - OSS Community + + + + + + + + + + + + +
+ +
+
+
+
+
+

+ + + + + + +
+ +

+ +
+ + + 注册于 + + + + + + + +
+ +
+ + + 编辑资料 + +
+ +
+
+
+ + +
+
+
+
文章
+
+
+
+
回复
+
+
+
+
浏览
+
+
+
+
点赞
+
+
+ + +
+ +
+
+

+ + 文章 +

+ = 10): ?> + + 查看全部 + + + +
+ +
+ +

还没有发表文章

+
+ +
+ +
+
+ + +
+
...
+
+ 📅 + 👁️ + ❤️ + 💬 +
+
+ +
+ +
+ + +
+
+

+ + 回复 +

+
+ +
+ +

还没有回复

+
+ +
+ +
+ Re: +
...
+
+
+ +
+ +
+
+
+ + + + + + diff --git a/website/community/register.php b/website/community/register.php new file mode 100644 index 0000000..daac221 --- /dev/null +++ b/website/community/register.php @@ -0,0 +1,142 @@ + + + + + + 注册 - OSS Community + + + + + + + + + + + +
+
+
+
+
+ + + +
+

创建账户

+

加入 OSS Community 开发者社区

+
+ +
+
+ +
+ + + + +
+ 只能包含字母、数字和下划线 +
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + + +
+ 至少 6 个字符 +
+ +
+ +
+ + + + +
+
+ +
+
+ + +
+ +
+

已有账户?立即登录

+
+
+ +
+
+
+
+
+
+
+ + + + + diff --git a/website/community/schema.sql b/website/community/schema.sql new file mode 100644 index 0000000..c2e5fab --- /dev/null +++ b/website/community/schema.sql @@ -0,0 +1,104 @@ +-- OSS Community 数据库结构 +CREATE DATABASE IF NOT EXISTS oss_community CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE oss_community; + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + avatar VARCHAR(255) DEFAULT '', + bio TEXT, + role ENUM('admin', 'moderator', 'member') DEFAULT 'member', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 分类表 +CREATE TABLE IF NOT EXISTS categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + icon VARCHAR(50) DEFAULT 'folder', + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 帖子表 +CREATE TABLE IF NOT EXISTS posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + category_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL UNIQUE, + content TEXT NOT NULL, + views INT DEFAULT 0, + likes INT DEFAULT 0, + is_pinned TINYINT(1) DEFAULT 0, + is_locked TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FULLTEXT(title, content) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 回复表 +CREATE TABLE IF NOT EXISTS replies ( + id INT AUTO_INCREMENT PRIMARY KEY, + post_id INT NOT NULL, + user_id INT NOT NULL, + content TEXT NOT NULL, + likes INT DEFAULT 0, + is_solution TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 点赞表 +CREATE TABLE IF NOT EXISTS likes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + post_id INT DEFAULT NULL, + reply_id INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_like (user_id, post_id, reply_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE SET NULL, + FOREIGN KEY (reply_id) REFERENCES replies(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 标签表 +CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + slug VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(7) DEFAULT '#06b6d4' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 帖子标签关联表 +CREATE TABLE IF NOT EXISTS post_tags ( + post_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (post_id, tag_id), + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 初始数据 +INSERT IGNORE INTO categories (name, slug, description, icon, sort_order) VALUES +('公告', 'announcements', '官方公告和重要通知', 'megaphone', 1), +('问答', 'q-a', '提问与解答,互助交流', 'question', 2), +('讨论', 'discussions', '技术讨论与想法分享', 'chat', 3), +('插件市场', 'plugins', '插件展示与推荐', 'puzzle', 4), +('反馈', 'feedback', 'Bug 反馈与功能建议', 'bug', 5); + +INSERT IGNORE INTO tags (name, slug, color) VALUES +('Go', 'go', '#00add8'), +('插件', 'plugin', '#8b5cf6'), +('安装', 'install', '#22c55e'), +('配置', 'config', '#3b82f6'), +('求助', 'help', '#f59e0b'); diff --git a/website/community/seed-announcements.sql b/website/community/seed-announcements.sql new file mode 100644 index 0000000..605f895 --- /dev/null +++ b/website/community/seed-announcements.sql @@ -0,0 +1,15 @@ +-- 插入公告数据 +INSERT IGNORE INTO posts (user_id, category_id, title, slug, content, is_pinned) VALUES +(1, + (SELECT id FROM categories WHERE slug = 'announcements' LIMIT 1), + '欢迎使用 OSS Community', + 'welcome-to-oss-community', + '# 欢迎使用 OSS 开发者社区!\n\n这是我们社区的第一篇公告。\n\n## 社区规则\n\n- 尊重他人,文明交流\n- 禁止发布违法不良信息\n- 鼓励分享技术经验\n- 提问前请先搜索已有帖子\n\n## 功能介绍\n\n- 📝 **发帖** - 分享你的技术经验\n- 💬 **回复** - 参与讨论,帮助他人\n- ❤️ **点赞** - 为优质内容点赞\n- 🏷️ **标签** - 使用标签分类帖子\n- 🔍 **搜索** - 快速找到感兴趣的内容\n\n## 快速开始\n\n1. 注册并登录你的账户\n2. 完善个人资料和简介\n3. 发表第一篇帖子\n4. 参与社区讨论\n\n> 如果你有任何建议或反馈,欢迎在反馈区发帖!\n\n祝你在社区玩得愉快! 🎉', + 1), + +(1, + (SELECT id FROM categories WHERE slug = 'announcements' LIMIT 1), + '社区功能更新日志', + 'community-changelog-v1', + '# 社区功能更新\n\n## v1.1.0 - 2026-04-04\n\n### 新增\n- ✨ 用户个人主页\n- ✨ 编辑个人资料(支持 Bio)\n- ✨ 我的文章页面\n- ✨ 文章统计(浏览、点赞、回复)\n\n### 优化\n- 🚀 响应式布局优化\n- 📱 移动端适配\n- 🎨 UI 样式改进\n\n### 修复\n- 🐛 数据库连接问题\n- 🐛 用户菜单交互问题\n\n---\n\n感谢大家的支持!', + 1); diff --git a/website/css/architecture.css b/website/css/architecture.css new file mode 100644 index 0000000..ca13478 --- /dev/null +++ b/website/css/architecture.css @@ -0,0 +1,110 @@ +/* ===== 架构图 ===== */ +#architecture { + padding: 120px 0; + position: relative; + z-index: 1; +} + +.arch-diagram { + max-width: 900px; + margin: 0 auto; +} + +.arch-layer { + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border); +} + +.arch-core { + background: rgba(6, 182, 212, 0.05); + border-color: rgba(6, 182, 212, 0.2); +} + +.arch-label { + text-align: center; + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; + color: var(--cyan-light); +} + +.arch-modules { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; +} + +.arch-module { + padding: 10px 20px; + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.2); + border-radius: 8px; + font-size: 14px; + font-weight: 500; +} + +.arch-connector { + height: 40px; + width: 2px; + background: linear-gradient(to bottom, var(--cyan), var(--blue)); + margin: 0 auto; + position: relative; +} + +.arch-connector::before, +.arch-connector::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--cyan); +} + +.arch-connector::before { top: -6px; } +.arch-connector::after { bottom: -6px; } + +.arch-plugins { + background: var(--bg-glass); +} + +.arch-plugin-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.arch-plugin { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + text-align: center; + transition: all 0.3s; +} + +.arch-plugin:hover { + border-color: var(--border-hover); + transform: translateY(-4px); +} + +.arch-plugin-title { + font-weight: 700; + font-size: 15px; + margin-bottom: 12px; + color: var(--cyan-light); +} + +.arch-plugin-items { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 4px; +} + +@media (max-width: 768px) { + .arch-plugin-grid { grid-template-columns: repeat(2, 1fr); } +} diff --git a/website/css/code-examples.css b/website/css/code-examples.css new file mode 100644 index 0000000..1d2b4a7 --- /dev/null +++ b/website/css/code-examples.css @@ -0,0 +1,63 @@ +/* ===== 代码示例 ===== */ +#examples { + padding: 120px 0; + background: var(--bg-secondary); + position: relative; + z-index: 1; +} + +.examples-grid { + max-width: 1000px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; +} + +.example-card:first-child { + grid-column: span 2; +} + +.example-card { + background: rgba(10, 15, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; +} + +.example-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.example-title { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); +} + +.example-lang { + padding: 4px 10px; + border-radius: 4px; + background: rgba(6, 182, 212, 0.1); + color: var(--cyan); + font-size: 12px; + font-weight: 600; +} + +.example-code { + padding: 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 1.8; + color: var(--text-secondary); + overflow-x: auto; +} + +@media (max-width: 768px) { + .examples-grid { grid-template-columns: 1fr; } + .example-card:first-child { grid-column: span 1; } +} diff --git a/website/css/community.css b/website/css/community.css new file mode 100644 index 0000000..a943d75 --- /dev/null +++ b/website/css/community.css @@ -0,0 +1,46 @@ +/* ===== 社区区域 ===== */ +#community { + padding: 120px 0; + background: var(--bg-secondary); + position: relative; + z-index: 1; +} + +.community-content { + max-width: 900px; + margin: 0 auto; + text-align: center; +} + +.community-links { + display: flex; + justify-content: center; + gap: 24px; + flex-wrap: wrap; +} + +.community-card { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 32px; + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text); + text-decoration: none; + font-weight: 600; + transition: all 0.3s; +} + +.community-card:hover { + border-color: var(--border-hover); + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(6, 182, 212, 0.1); +} + +.community-card svg { + width: 24px; + height: 24px; + color: var(--cyan); +} diff --git a/website/css/dock.css b/website/css/dock.css new file mode 100644 index 0000000..d35161f --- /dev/null +++ b/website/css/dock.css @@ -0,0 +1,156 @@ +/* ===== Dock 侧边栏 ===== */ +#dock { + position: fixed; + right: 20px; + top: 50%; + transform: translateY(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 8px; + background: rgba(3, 7, 18, 0.7); + backdrop-filter: blur(16px); + border: 1px solid var(--border); + border-radius: 16px; +} + +.dock-item { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.25s ease; + position: relative; +} + +.dock-item:hover, .dock-item.active { + color: var(--cyan-light); + background: rgba(6, 182, 212, 0.15); + transform: scale(1.15); +} + +.dock-item svg { + width: 18px; + height: 18px; +} + +.dock-separator { + width: 20px; + height: 1px; + background: var(--border); + margin: 4px 0; +} + +/* 用户头像样式 */ +.dock-user-avatar { + position: relative; + cursor: pointer; + flex-direction: column; + gap: 4px; +} + +.dock-user-avatar svg { + color: var(--cyan); +} + +.dock-user-avatar:hover { + color: var(--cyan-light); + background: rgba(6, 182, 212, 0.15); + transform: scale(1.15); +} + +/* 称号徽章 */ +.user-title-badge { + position: absolute; + bottom: -18px; + left: 50%; + transform: translateX(-50%); + font-size: 9px; + font-weight: 600; + padding: 1px 4px; + border-radius: 4px; + background: linear-gradient(135deg, #f59e0b, #f97316); + color: #fff; + white-space: nowrap; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.3); + pointer-events: none; +} + +/* 用户菜单按钮样式 */ +.dock-user-menu-btn { + position: relative; + cursor: pointer; +} + +.dock-user-menu-btn svg { + color: var(--text-secondary); +} + +.dock-user-menu-btn:hover { + color: var(--cyan-light); + background: rgba(6, 182, 212, 0.15); + transform: scale(1.15); +} + +/* Tooltip */ +.dock-item::before { + content: attr(data-tooltip); + position: absolute; + right: calc(100% + 10px); + top: 50%; + transform: translateY(-50%) scale(0.9); + padding: 4px 8px; + border-radius: 4px; + background: #1f2937; + border: 1px solid var(--border); + color: #fff; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: all 0.2s ease; +} + +.dock-item:hover::before { + opacity: 1; + transform: translateY(-50%) scale(1); +} + +/* 响应式 */ +@media (max-width: 768px) { + #dock { + right: auto; + left: 50%; + top: auto; + bottom: 12px; + transform: translateX(-50%); + flex-direction: row; + gap: 4px; + padding: 8px 12px; + } + .dock-separator { + width: 1px; + height: 20px; + margin: 0 4px; + } + .dock-item::before { + right: auto; + top: auto; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) scale(0.9); + } + .dock-item:hover::before { + transform: translateX(-50%) scale(1); + } +} diff --git a/website/css/features.css b/website/css/features.css new file mode 100644 index 0000000..d39f41a --- /dev/null +++ b/website/css/features.css @@ -0,0 +1,98 @@ +/* ===== 特性区域 ===== */ +#features { + padding: 120px 0; + position: relative; + z-index: 1; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.feature-card { + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 20px; + padding: 32px; + transition: all 0.4s ease; + position: relative; + overflow: hidden; +} + +.feature-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.05), transparent); + opacity: 0; + transition: opacity 0.4s; +} + +.feature-card:hover { + border-color: var(--border-hover); + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(6, 182, 212, 0.1); +} + +.feature-card:hover::before { opacity: 1; } + +.feature-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + transition: transform 0.3s; +} + +.feature-card:hover .feature-icon { + transform: scale(1.1); +} + +.feature-icon svg { + width: 28px; + height: 28px; + color: #fff; +} + +.feature-card h3 { + font-size: 20px; + font-weight: 700; + margin-bottom: 12px; +} + +.feature-card p { + font-size: 15px; + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 20px; +} + +.feature-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.feature-tags span { + padding: 4px 12px; + border-radius: 6px; + background: rgba(6, 182, 212, 0.08); + border: 1px solid rgba(6, 182, 212, 0.15); + color: var(--cyan-light); + font-size: 12px; + font-weight: 500; +} + +@media (max-width: 1024px) { + .features-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 640px) { + .features-grid { grid-template-columns: 1fr; } +} diff --git a/website/css/footer.css b/website/css/footer.css new file mode 100644 index 0000000..973e25d --- /dev/null +++ b/website/css/footer.css @@ -0,0 +1,79 @@ +/* ===== Footer ===== */ +#footer { + padding: 80px 0 40px; + border-top: 1px solid var(--border); + position: relative; + z-index: 1; +} + +.footer-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + display: grid; + grid-template-columns: 1fr 2fr; + gap: 64px; + margin-bottom: 64px; +} + +.footer-brand p { + color: var(--text-muted); + font-size: 14px; + margin-top: 12px; +} + +.footer-logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 18px; +} + +.footer-links { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; +} + +.footer-col h4 { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 16px; +} + +.footer-col a { + display: block; + color: var(--text-muted); + text-decoration: none; + font-size: 14px; + margin-bottom: 10px; + transition: color 0.2s; +} + +.footer-col a:hover { color: var(--cyan); } + +.footer-bottom { + text-align: center; + padding-top: 32px; + border-top: 1px solid var(--border); + max-width: 1200px; + margin: 0 auto; + padding-left: 24px; + padding-right: 24px; +} + +.footer-bottom p { + color: var(--text-muted); + font-size: 13px; +} + +@media (max-width: 768px) { + .footer-container { grid-template-columns: 1fr; gap: 40px; } + .footer-links { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 480px) { + .footer-links { grid-template-columns: 1fr; } +} diff --git a/website/css/hero.css b/website/css/hero.css new file mode 100644 index 0000000..dea99a8 --- /dev/null +++ b/website/css/hero.css @@ -0,0 +1,259 @@ +/* ===== Hero 区域 ===== */ +#hero { + min-height: 100vh; + display: flex; + align-items: center; + position: relative; + z-index: 1; + padding-top: 64px; +} + +.hero-container { + max-width: 1400px; + margin: 0 auto; + padding: 60px 24px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 80px; + align-items: center; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border-radius: 999px; + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.2); + margin-bottom: 32px; + font-size: 14px; + color: rgba(34, 211, 238, 0.9); +} + +.badge-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--cyan); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.hero-title { + font-size: clamp(48px, 8vw, 80px); + font-weight: 900; + line-height: 0.95; + margin-bottom: 24px; +} + +.title-line { display: block; } + +.hero-subtitle { + font-size: clamp(20px, 3vw, 28px); + color: var(--text-secondary); + font-weight: 300; + margin-bottom: 12px; +} + +.hero-desc { + font-size: 16px; + color: var(--text-muted); + max-width: 520px; + margin-bottom: 48px; + line-height: 1.8; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 64px; +} + +.hero-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.stat { text-align: left; } + +.stat-num { + display: block; + font-size: 28px; + font-weight: 800; + color: #fff; + margin-bottom: 4px; +} + +.stat-num .stat-suffix { + font-size: 20px; + color: var(--text-secondary); +} + +.stat-label { + font-size: 13px; + color: var(--text-muted); +} + +/* 右侧代码区域 */ +.hero-visual { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.orbit { + position: absolute; + border-radius: 50%; + border: 1px solid rgba(6, 182, 212, 0.1); +} + +.orbit-1 { + inset: 15%; + animation: spin 40s linear infinite; +} + +.orbit-2 { + inset: 25%; + animation: spin 30s linear infinite reverse; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.orbit-dot { + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 20px rgba(6, 182, 212, 0.5); +} + +.orbit-dot-lg { + width: 16px; + height: 16px; + background: var(--blue); + box-shadow: 0 0 24px rgba(59, 130, 246, 0.5); +} + +.code-window { + background: rgba(10, 15, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 0; + width: 100%; + max-width: 480px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.code-header { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.code-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.code-dot-red { background: #ef4444; } +.code-dot-yellow { background: #eab308; } +.code-dot-green { background: #22c55e; } + +.code-title { + margin-left: 12px; + font-size: 13px; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; +} + +.code-body { + padding: 20px 24px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + line-height: 1.8; + color: var(--text-secondary); + overflow-x: auto; +} + +/* 滚动指示器 */ +.scroll-indicator { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 13px; + animation: bounce 2s ease-in-out infinite; +} + +@keyframes bounce { + 0%, 100% { transform: translateX(-50%) translateY(0); } + 50% { transform: translateX(-50%) translateY(8px); } +} + +.scroll-mouse { + width: 24px; + height: 36px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + position: relative; +} + +.scroll-mouse::after { + content: ''; + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 8px; + background: var(--cyan); + border-radius: 2px; + animation: scroll-dot 2s ease-in-out infinite; +} + +@keyframes scroll-dot { + 0%, 100% { opacity: 1; top: 6px; } + 50% { opacity: 0.3; top: 16px; } +} + +/* 响应式 */ +@media (max-width: 1024px) { + .hero-container { + grid-template-columns: 1fr; + gap: 48px; + text-align: center; + } + .hero-desc, .hero-actions, .hero-stats { + margin-left: auto; + margin-right: auto; + } + .hero-stats { max-width: 400px; } + .stat { text-align: center; } +} + +@media (max-width: 640px) { + .hero-stats { grid-template-columns: 1fr; gap: 16px; } +} diff --git a/website/css/main.css b/website/css/main.css new file mode 100644 index 0000000..d33eb5d --- /dev/null +++ b/website/css/main.css @@ -0,0 +1,160 @@ +/* ===== 全局样式 ===== */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #030712; + --bg-secondary: #020510; + --bg-glass: rgba(255, 255, 255, 0.02); + --border: rgba(255, 255, 255, 0.05); + --border-hover: rgba(6, 182, 212, 0.3); + --cyan: #06b6d4; + --cyan-light: #22d3ee; + --blue: #3b82f6; + --purple: #8b5cf6; + --text: #fff; + --text-secondary: #9ca3af; + --text-muted: #6b7280; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 80px; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + overflow-x: hidden; +} + +/* 粒子画布 */ +#particles { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +/* 通用 */ +.section-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +.section-header { + text-align: center; + margin-bottom: 64px; +} + +.section-badge { + display: inline-block; + padding: 6px 16px; + border-radius: 999px; + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.2); + color: var(--cyan-light); + font-size: 14px; + font-weight: 500; + margin-bottom: 20px; +} + +.section-title { + font-size: clamp(32px, 5vw, 48px); + font-weight: 800; + margin-bottom: 16px; + line-height: 1.2; +} + +.section-desc { + font-size: 18px; + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto; +} + +.gradient-text { + background: linear-gradient(135deg, var(--cyan), var(--blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.glass { + background: var(--bg-glass); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 16px; +} + +/* 按钮 */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 14px 28px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + text-decoration: none; + transition: all 0.3s ease; + cursor: pointer; + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--cyan), var(--blue)); + color: #fff; +} + +.btn-primary:hover { + box-shadow: 0 8px 30px rgba(6, 182, 212, 0.3); + transform: translateY(-2px); +} + +.btn-outline { + background: transparent; + color: var(--text); + border: 1px solid rgba(6, 182, 212, 0.3); +} + +.btn-outline:hover { + background: rgba(6, 182, 212, 0.1); + transform: translateY(-2px); +} + +.btn-lg { + padding: 18px 36px; + font-size: 18px; +} + +.btn-arrow { + width: 20px; + height: 20px; + transition: transform 0.3s ease; +} + +.btn:hover .btn-arrow { + transform: translateX(4px); +} + +/* 代码样式 */ +.code-comment { color: #6b7280; font-style: italic; } +.code-str { color: #34d399; } +.code-keyword { color: #c084fc; } +.code-func { color: #60a5fa; } +.code-bool { color: #f472b6; } +.code-type { color: #fbbf24; } +.code-cmd { color: #9ca3af; } + +/* 响应式 */ +@media (max-width: 768px) { + .section-container { padding: 0 16px; } + .section-header { margin-bottom: 40px; } +} diff --git a/website/css/page.css b/website/css/page.css new file mode 100644 index 0000000..aae5af5 --- /dev/null +++ b/website/css/page.css @@ -0,0 +1,396 @@ +/* ===== 通用页面样式 ===== */ +.page-content { + max-width: 1100px; + margin: 0 auto; + padding: 100px 80px 80px 24px; + min-height: 100vh; +} + +.page-header { + text-align: center; + margin-bottom: 64px; +} + +.page-title { + font-size: clamp(32px, 5vw, 48px); + font-weight: 800; + margin-bottom: 16px; + line-height: 1.2; +} + +.page-desc { + font-size: 18px; + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto; +} + +/* 特性网格 */ +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.feature-card { + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 20px; + padding: 32px; + transition: all 0.4s ease; +} + +.feature-card:hover { + border-color: var(--border-hover); + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(6, 182, 212, 0.1); +} + +.feature-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + transition: transform 0.3s; +} + +.feature-card:hover .feature-icon { transform: scale(1.1); } + +.feature-icon svg { width: 28px; height: 28px; color: #fff; } + +.feature-card h3 { font-size: 20px; font-weight: 700; margin-bottom: 12px; } + +.feature-card p { + font-size: 15px; + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 20px; +} + +.feature-tags { display: flex; flex-wrap: wrap; gap: 8px; } + +.feature-tags span { + padding: 4px 12px; + border-radius: 6px; + background: rgba(6, 182, 212, 0.08); + border: 1px solid rgba(6, 182, 212, 0.15); + color: var(--cyan-light); + font-size: 12px; + font-weight: 500; +} + +/* 插件页面 */ +.pkg-format { text-align: center; margin-bottom: 48px; } + +.pkg-card { + font-family: 'JetBrains Mono', monospace; + font-size: clamp(20px, 4vw, 36px); + font-weight: 700; + padding: 32px; + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 16px; + display: inline-block; + margin-bottom: 32px; +} + +.pkg-symbol { color: var(--text-muted); } +.pkg-highlight { color: var(--cyan); } +.pkg-version { color: var(--blue); } + +.pkg-explain { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + text-align: center; +} + +.explain-symbol { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + font-weight: 600; + color: var(--cyan-light); + margin-bottom: 8px; +} + +.explain-text { font-size: 14px; color: var(--text-muted); } + +/* 终端演示 */ +.install-demo { + max-width: 700px; + margin: 0 auto 48px; + background: rgba(10, 15, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; +} + +.demo-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.demo-dot { width: 12px; height: 12px; border-radius: 50%; } +.demo-dot-red { background: #ef4444; } +.demo-dot-yellow { background: #eab308; } +.demo-dot-green { background: #22c55e; } + +.demo-title { margin-left: 12px; font-size: 13px; color: var(--text-muted); } + +.demo-body { + padding: 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; +} + +.cmd-line { display: flex; gap: 12px; margin-bottom: 8px; color: var(--text-secondary); } +.prompt { color: var(--cyan); font-weight: 600; } +.cmd-text { color: #e5e7eb; } +.cmd-output { color: #9ca3af; font-size: 13px; margin-bottom: 4px; } +.cmd-output.success { color: #34d399; } +.cmd-output.indent { padding-left: 24px; } + +/* 命令表格 */ +.pkg-table { max-width: 800px; margin: 0 auto; } +.pkg-table-title { font-size: 24px; font-weight: 700; margin-bottom: 20px; text-align: center; } + +.pkg-table table { + width: 100%; + border-collapse: collapse; +} + +.pkg-table th, .pkg-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +.pkg-table th { + color: var(--text-muted); + font-weight: 600; + font-size: 13px; +} + +.pkg-table td code { + font-family: 'JetBrains Mono', monospace; + background: rgba(6, 182, 212, 0.1); + padding: 2px 8px; + border-radius: 4px; + font-size: 13px; + color: var(--cyan-light); +} + +/* 架构图 */ +.arch-diagram { max-width: 900px; margin: 0 auto 64px; } + +.arch-layer { + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border); +} + +.arch-core { + background: rgba(6, 182, 212, 0.05); + border-color: rgba(6, 182, 212, 0.2); +} + +.arch-label { + text-align: center; + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; + color: var(--cyan-light); +} + +.arch-modules { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; +} + +.arch-module { + padding: 10px 20px; + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.2); + border-radius: 8px; + font-size: 14px; + font-weight: 500; +} + +.arch-connector { + height: 40px; + width: 2px; + background: linear-gradient(to bottom, var(--cyan), var(--blue)); + margin: 0 auto; + position: relative; +} + +.arch-connector::before, +.arch-connector::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--cyan); +} + +.arch-connector::before { top: -6px; } +.arch-connector::after { bottom: -6px; } + +.arch-plugins { background: var(--bg-glass); } + +.arch-plugin-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.arch-plugin { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + text-align: center; + transition: all 0.3s; +} + +.arch-plugin:hover { + border-color: var(--border-hover); + transform: translateY(-4px); +} + +.arch-plugin-title { + font-weight: 700; + font-size: 15px; + margin-bottom: 12px; + color: var(--cyan-light); +} + +.arch-plugin-items { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 4px; +} + +/* 数据流 */ +.arch-flow { max-width: 900px; margin: 0 auto; } +.arch-flow-title { text-align: center; font-size: 28px; font-weight: 800; margin-bottom: 32px; } + +.arch-flow-steps { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; +} + +.flow-step { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 12px; + font-size: 14px; + font-weight: 500; +} + +.flow-step-num { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} + +.flow-arrow { color: var(--cyan); font-size: 20px; font-weight: 700; } + +/* 快速开始 */ +.steps-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 64px; +} + +.step-card { + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + text-align: center; + transition: all 0.3s; +} + +.step-card:hover { + border-color: var(--border-hover); + transform: translateY(-4px); +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + font-size: 20px; + font-weight: 800; + margin-bottom: 20px; +} + +.step-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; } + +.step-code { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 1.8; + color: var(--text-secondary); + text-align: left; + background: rgba(10, 15, 30, 0.6); + padding: 16px; + border-radius: 8px; +} + +.quickstart-links { + display: flex; + justify-content: center; + gap: 16px; + flex-wrap: wrap; +} + +/* 响应式 */ +@media (max-width: 1024px) { + .features-grid { grid-template-columns: repeat(2, 1fr); } + .arch-plugin-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .page-content { padding-left: 24px; } + .features-grid { grid-template-columns: 1fr; } + .arch-plugin-grid { grid-template-columns: 1fr; } + .steps-grid { grid-template-columns: 1fr; } + .arch-flow-steps { flex-direction: column; } + .flow-arrow { transform: rotate(90deg); } + .pkg-explain { grid-template-columns: 1fr; } + .pkg-table { overflow-x: auto; } +} diff --git a/website/css/plugins.css b/website/css/plugins.css new file mode 100644 index 0000000..dd7f06f --- /dev/null +++ b/website/css/plugins.css @@ -0,0 +1,110 @@ +/* ===== 插件生态 ===== */ +#plugins { + padding: 120px 0; + background: var(--bg-secondary); + position: relative; + z-index: 1; +} + +.plugins-content { + max-width: 900px; + margin: 0 auto; +} + +.pkg-format { text-align: center; margin-bottom: 48px; } + +.pkg-card { + font-family: 'JetBrains Mono', monospace; + font-size: clamp(20px, 4vw, 36px); + font-weight: 700; + padding: 32px; + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 16px; + display: inline-block; + margin-bottom: 32px; +} + +.pkg-symbol { color: var(--text-muted); } +.pkg-highlight { color: var(--cyan); } +.pkg-version { color: var(--blue); } + +.pkg-explain { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + text-align: center; +} + +.explain-symbol { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + font-weight: 600; + color: var(--cyan-light); + margin-bottom: 8px; +} + +.explain-text { + font-size: 14px; + color: var(--text-muted); +} + +.install-demo { + background: rgba(10, 15, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; +} + +.demo-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.demo-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.demo-dot-red { background: #ef4444; } +.demo-dot-yellow { background: #eab308; } +.demo-dot-green { background: #22c55e; } + +.demo-title { + margin-left: 12px; + font-size: 13px; + color: var(--text-muted); +} + +.demo-body { + padding: 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; +} + +.cmd-line { + display: flex; + gap: 12px; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.prompt { color: var(--cyan); font-weight: 600; } +.cmd-text { color: #e5e7eb; } + +.cmd-output { + color: #9ca3af; + font-size: 13px; + margin-bottom: 4px; +} + +.cmd-output.success { color: #34d399; } +.cmd-output.indent { padding-left: 24px; } + +@media (max-width: 640px) { + .pkg-explain { grid-template-columns: 1fr; } +} diff --git a/website/css/quickstart.css b/website/css/quickstart.css new file mode 100644 index 0000000..f0eed4e --- /dev/null +++ b/website/css/quickstart.css @@ -0,0 +1,65 @@ +/* ===== 快速开始 ===== */ +#quickstart { + padding: 120px 0; + position: relative; + z-index: 1; +} + +.steps-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 64px; +} + +.step-card { + background: var(--bg-glass); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + text-align: center; + transition: all 0.3s; +} + +.step-card:hover { + border-color: var(--border-hover); + transform: translateY(-4px); +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + font-size: 20px; + font-weight: 800; + margin-bottom: 20px; +} + +.step-title { + font-size: 20px; + font-weight: 700; + margin-bottom: 16px; +} + +.step-code { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 1.8; + color: var(--text-secondary); + text-align: left; + background: rgba(10, 15, 30, 0.6); + padding: 16px; + border-radius: 8px; +} + +.quickstart-cta { + text-align: center; +} + +@media (max-width: 768px) { + .steps-grid { grid-template-columns: 1fr; } +} diff --git a/website/css/stats.css b/website/css/stats.css new file mode 100644 index 0000000..7ee4b8e --- /dev/null +++ b/website/css/stats.css @@ -0,0 +1,52 @@ +/* ===== 统计区域 ===== */ +#stats { + padding: 80px 0; + position: relative; + z-index: 1; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.05), rgba(59, 130, 246, 0.05)); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px; + max-width: 1000px; + margin: 0 auto; + text-align: center; +} + +.stat-card { + padding: 24px; +} + +.stat-number { + font-size: 48px; + font-weight: 900; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-suffix { + font-size: 32px; + font-weight: 800; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-label { + display: block; + margin-top: 8px; + font-size: 16px; + color: var(--text-muted); + font-weight: 500; +} + +@media (max-width: 768px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); gap: 24px; } +} diff --git a/website/features.html b/website/features.html new file mode 100644 index 0000000..b090d70 --- /dev/null +++ b/website/features.html @@ -0,0 +1,91 @@ + + + + + + 核心特性 - Future OSS + + + + + + + + + + + + + + + + + +
+ +
+
+ 核心能力 +

为何选择 Future OSS

+

每个模块都为生产环境而设计,为企业提供开箱即用的稳定性保障

+
+ +
+
+
+ +
+

一切皆插件

+

协议适配、中间件、通知渠道、数据库驱动……所有功能均以插件形式加载。框架本身是空壳,只提供插件管理、事件总线和配置系统。

+
热插拔按需加载隔离运行
+
+
+
+ +
+

依赖自动解析

+

自动识别多级上游依赖,拓扑排序决定加载顺序,循环依赖检测。插件只需声明依赖关系,运行时自动注入。

+
拓扑排序循环检测自动注入
+
+
+
+ +
+

熔断与降级

+

内置熔断器,失败 N 次后自动熔断。支持 6 种降级策略:静态值、缓存、默认值、上游、空、自定义函数。

+
自动熔断6 种降级自动恢复
+
+
+
+ +
+

包管理系统

+

类似 npm 的包管理体验。@{作者/插件名}<版本> 格式,一键安装、卸载、更新、同步。从 Gitee 仓库自动拉取。

+
一键安装版本管理自动同步
+
+
+
+ +
+

事件驱动架构

+

发布/订阅事件总线,支持通配符匹配。12 种系统事件,插件间松耦合通信。消息总线支持点对点通信。

+
发布订阅通配符点对点
+
+
+
+ +
+

丰富配置系统

+

100+ 配置参数,覆盖服务器、日志、认证、数据库、缓存、监控、安全、消息队列、任务调度、插件系统等模块。

+
100+ 参数热重载文件监听
+
+
+
+ + + + + + + + diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..dd5867b --- /dev/null +++ b/website/index.html @@ -0,0 +1,83 @@ + + + + + + Future OSS - 一切皆为插件的开发者工具运行时框架 + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + 2026 · 插件驱动 · 一切皆可扩展 +
+

+ OSS + Runtime +

+

一切皆为插件的开发者工具运行时框架

+

协议、中间件、通知渠道……所有功能均以插件形式加载。内置熔断降级、依赖自动解析、事件驱动等企业级稳定性机制。

+
+ + 快速开始 + + + 了解更多 +
+
+
100+配置参数
+
10插件类型
+
自动依赖解析
+
+
+
+
+
+
+
+ + + + main.go +
+
// 1. 加载配置
+cfg, _ := config.Load("config.yaml")
+
+// 2. 安装插件
+$ oss pkg install \
+  @{Falck/http-server}<1.0.0>
+
+// 3. 启动服务
+$ oss serve
+
+
+
+
+ + + + + + + + diff --git a/website/js/animations.js b/website/js/animations.js new file mode 100644 index 0000000..83acbfa --- /dev/null +++ b/website/js/animations.js @@ -0,0 +1,35 @@ +// 动画入口 +gsap.registerPlugin(ScrollTrigger); + +// 页面元素入场 +gsap.utils.toArray('.feature-card, .step-card, .arch-layer, .arch-plugin').forEach((el, i) => { + gsap.fromTo(el, + { y: 40, opacity: 0 }, + { + y: 0, opacity: 1, duration: 0.6, + ease: 'power3.out', + scrollTrigger: { trigger: el, start: 'top 85%' }, + delay: (i % 4) * 0.1 + } + ); +}); + +// Hero 动画 +if (document.querySelector('.hero-content')) { + gsap.fromTo('.hero-content > *', + { y: 50, opacity: 0 }, + { y: 0, opacity: 1, duration: 0.8, stagger: 0.1, ease: 'power3.out', delay: 0.2 } + ); + gsap.fromTo('.hero-visual', + { scale: 0.85, opacity: 0 }, + { scale: 1, opacity: 1, duration: 1, ease: 'back.out(1.4)', delay: 0.4 } + ); +} + +// 平滑滚动 +document.querySelectorAll('a[href^="#"]').forEach(a => { + a.addEventListener('click', e => { + const target = document.querySelector(a.getAttribute('href')); + if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); } + }); +}); diff --git a/website/js/app.js b/website/js/app.js new file mode 100644 index 0000000..84eedba --- /dev/null +++ b/website/js/app.js @@ -0,0 +1,16 @@ +// 主应用入口 +document.addEventListener('DOMContentLoaded', () => { + // 平滑滚动到锚点 + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function(e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + console.log('%c Future OSS ', 'background: linear-gradient(135deg, #06b6d4, #3b82f6); color: #fff; padding: 8px 16px; border-radius: 4px; font-weight: bold;'); + console.log('%c一切皆为插件的开发者工具运行时框架', 'color: #9ca3af; font-size: 14px;'); +}); diff --git a/website/js/dock.js b/website/js/dock.js new file mode 100644 index 0000000..8aca052 --- /dev/null +++ b/website/js/dock.js @@ -0,0 +1,75 @@ +/** + * Dock 侧边栏组件 + * 在所有 HTML 页面中统一引用 + */ + +(function () { + 'use strict'; + + // Dock HTML 模板 + // current: 当前页面文件名,用于设置 active 状态 + function renderDock(current) { + const items = [ + { href: 'index.html', tooltip: '首页', svg: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' }, + { href: 'features.html', tooltip: '特性', svg: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z' }, + { href: 'plugins.html', tooltip: '插件', svg: 'M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z' }, + { href: 'architecture.html', tooltip: '架构', svg: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z' }, + { separator: true }, + { href: 'quickstart.html', tooltip: '快速开始', svg: 'M13 10V3L4 14h7v7l9-11h-7z' }, + { separator: true }, + { href: 'community/', tooltip: '社区', svg: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, + { href: 'https://gitee.com/starlight-apk/feature-oss', tooltip: '源码', svg: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4', target: '_blank' } + ]; + + let html = '
\n'; + + items.forEach(item => { + if (item.separator) { + html += '
\n'; + } else { + const isActive = item.href === current ? ' active' : ''; + const targetAttr = item.target ? ` target="${item.target}"` : ''; + html += ` \n`; + html += ` \n`; + html += ` \n`; + } + }); + + html += '
'; + return html; + } + + // 获取当前页面文件名 + function getCurrentPage() { + const path = window.location.pathname; + const filename = path.substring(path.lastIndexOf('/') + 1); + return filename || 'index.html'; + } + + // 初始化:将 dock 插入到 body 的第一个子元素之前 + function initDock() { + const current = getCurrentPage(); + const dockHTML = renderDock(current); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = dockHTML; + const dockElement = tempDiv.firstElementChild; + + const body = document.body; + const firstChild = body.firstChild; + if (firstChild) { + body.insertBefore(dockElement, firstChild); + } else { + body.appendChild(dockElement); + } + } + + // DOM 加载完成后初始化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDock); + } else { + initDock(); + } + + // 对外暴露(如果需要手动调用) + window.OSSDock = { init: initDock, render: renderDock }; +})(); diff --git a/website/js/particles.js b/website/js/particles.js new file mode 100644 index 0000000..2337d98 --- /dev/null +++ b/website/js/particles.js @@ -0,0 +1,83 @@ +// 粒子背景动画 +const canvas = document.getElementById('particles'); +const ctx = canvas.getContext('2d'); + +let width, height, particles = []; + +function resize() { + width = canvas.width = window.innerWidth; + height = canvas.height = window.innerHeight; +} + +class Particle { + constructor() { + this.reset(); + } + + reset() { + this.x = Math.random() * width; + this.y = Math.random() * height; + this.size = 1 + Math.random() * 2; + this.speedX = -0.2 + Math.random() * 0.4; + this.speedY = -0.2 + Math.random() * 0.4; + this.opacity = 0.1 + Math.random() * 0.3; + } + + update() { + this.x += this.speedX; + this.y += this.speedY; + + if (this.x < 0 || this.x > width || this.y < 0 || this.y > height) { + this.reset(); + } + } + + draw() { + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); + ctx.fillStyle = `rgba(6, 182, 212, ${this.opacity})`; + ctx.fill(); + } +} + +function init() { + resize(); + const count = Math.min(80, Math.floor((width * height) / 15000)); + particles = Array.from({ length: count }, () => new Particle()); +} + +function animate() { + ctx.clearRect(0, 0, width, height); + + // 绘制连线 + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 120) { + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.strokeStyle = `rgba(6, 182, 212, ${0.05 * (1 - dist / 120)})`; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + } + } + + particles.forEach(p => { + p.update(); + p.draw(); + }); + + requestAnimationFrame(animate); +} + +window.addEventListener('resize', () => { + resize(); +}); + +init(); +animate(); diff --git a/website/js/spa.js b/website/js/spa.js new file mode 100644 index 0000000..2900bac --- /dev/null +++ b/website/js/spa.js @@ -0,0 +1,100 @@ +/** + * 极简软重定向 (SPA Router) + * 拦截所有站内链接,使用 fetch + DOM 替换实现无刷新跳转 + */ +(function() { + document.addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (!link || link.target === '_blank' || link.href.startsWith('javascript:')) return; + + const url = new URL(link.href); + if (url.origin !== window.location.origin) return; // 仅拦截站内 + + e.preventDefault(); + navigate(url.pathname + url.search, true); + }); + + window.addEventListener('popstate', function(e) { + if (e.state && e.state.html) { + document.title = e.state.title; + document.querySelector('main').innerHTML = e.state.html; + initPage(); + } else { + navigate(window.location.pathname + window.location.search, false); + } + }); + + async function navigate(url, push) { + try { + const res = await fetch(url); + if (!res.ok) { window.location.href = url; return; } + + const html = await res.text(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + + // 1. 更新标题 + document.title = doc.title; + + // 2. 替换 Main 内容 + const newMain = doc.querySelector('main'); + const oldMain = document.querySelector('main'); + if (newMain && oldMain) { + oldMain.innerHTML = newMain.innerHTML; + } + + // 3. 更新 URL + if (push) { + history.pushState({ + url: url, + html: newMain ? newMain.innerHTML : '', + title: doc.title + }, '', url); + window.scrollTo(0, 0); + } + + // 4. 更新 Dock 高亮 + document.querySelectorAll('#dock .dock-item').forEach(el => { + const href = el.getAttribute('href'); + el.classList.toggle('active', href && url.startsWith(href)); + }); + + // 5. 重新注入脚本/逻辑 + injectScripts(doc); + initPage(); + + } catch (err) { + console.error(err); + window.location.href = url; + } + } + + function injectScripts(doc) { + doc.scripts.forEach(oldScript => { + const newScript = document.createElement('script'); + Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)); + if (oldScript.src) { + newScript.src = oldScript.src; + document.body.appendChild(newScript); + } else { + newScript.textContent = oldScript.textContent; + document.body.appendChild(newScript); + } + }); + } + + // 通用页面初始化入口 + function initPage() { + // 尝试调用社区模块初始化 + if (typeof initCommunity === 'function') { + // 如果脚本已加载,直接调用 + initCommunity(); + } else { + // 否则动态加载 + if (location.pathname.includes('/community/') || location.pathname.endsWith('/community/')) { + const s = document.createElement('script'); + s.src = '/community/assets/js/community.js'; + document.body.appendChild(s); + } + } + } +})(); \ No newline at end of file diff --git a/website/logo.svg b/website/logo.svg new file mode 100644 index 0000000..5ec660b --- /dev/null +++ b/website/logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/plugins.html b/website/plugins.html new file mode 100644 index 0000000..a627047 --- /dev/null +++ b/website/plugins.html @@ -0,0 +1,113 @@ + + + + + + 插件生态 - Future OSS + + + + + + + + + + + + + + + + + +
+ +
+
+ 插件生态 +

包名格式与安装流程

+

简洁且富有表现力的包名格式,让插件管理像呼吸一样自然

+
+ +
+
+
+ @{ + 作者 + / + 插件名 + }< + 版本 + > +
+
+
+
@{ }
+
大括号包裹作者和插件名
+
+
+
/
+
分隔作者与插件名
+
+
+
< >
+
尖括号包裹语义化版本号
+
+
+
+ +
+
+ + + + 终端 +
+
+
+ $ + oss pkg install @{Falck/http-server}<1.0.0> +
+
✅ http-server@1.0.0 安装完成 (1.2s)
+
+ $ + oss pkg list +
+
已安装 1 个包:
+
http-server@1.0.0 - HTTP 协议适配器
+
+ $ + oss pkg update +
+
✅ 所有包已是最新版本
+
+
+ +
+

常用命令

+ + + + + + + + + + + + + +
命令别名说明
oss pkg install @{x/y}<v>i / add安装指定版本的插件包
oss pkg remove @{x/y}rm / uninstall卸载已安装的插件
oss pkg listls列出所有已安装的包
oss pkg update [包名]—更新单个或所有包
oss pkg sync—从远程仓库同步所有包
oss pkg init—初始化 package.json
oss pkg clean—清理下载缓存
+
+
+
+ + + + + + + + diff --git a/website/quickstart.html b/website/quickstart.html new file mode 100644 index 0000000..180c991 --- /dev/null +++ b/website/quickstart.html @@ -0,0 +1,72 @@ + + + + + + 快速开始 - Future OSS + + + + + + + + + + + + + + + + + +
+ +
+
+ 快速开始 +

三步即可运行

+

从克隆代码到启动服务,只需几分钟

+
+ +
+
+
1
+

克隆代码

+
git clone https://gitee.com/starlight-apk/feature-oss.git
+cd feature-oss
+
+
+
2
+

编译构建

+
go mod download
+go build -o bin/oss .
+./bin/oss init
+
+
+
3
+

启动服务

+
./bin/oss serve
+# 访问 localhost:8080
+
+
+ +
+ + 查看完整文档 + + + + 前往 Gitee 仓库 + +
+
+ + + + + + + + diff --git a/website/sitemap.xml b/website/sitemap.xml new file mode 100644 index 0000000..2e200e9 --- /dev/null +++ b/website/sitemap.xml @@ -0,0 +1,97 @@ + + + + + + https://oss-runtime.dev/ + 2026-04-05 + weekly + 1.0 + + + + https://oss-runtime.dev/features + 2026-04-05 + monthly + 0.8 + + + + https://oss-runtime.dev/architecture + 2026-04-05 + monthly + 0.7 + + + + https://oss-runtime.dev/quickstart + 2026-04-05 + monthly + 0.9 + + + + https://oss-runtime.dev/plugins + 2026-04-05 + weekly + 0.8 + + + + + https://oss-runtime.dev/community/ + 2026-04-05 + daily + 0.7 + + + + https://oss-runtime.dev/community/post.php + 2026-04-05 + daily + 0.6 + + + + https://oss-runtime.dev/community/profile.php + 2026-04-05 + weekly + 0.5 + + + + https://oss-runtime.dev/community/my-posts.php + 2026-04-05 + weekly + 0.4 + + + + https://oss-runtime.dev/community/editor.php + 2026-04-05 + monthly + 0.4 + + + + https://oss-runtime.dev/community/edit-profile.php + 2026-04-05 + monthly + 0.3 + + + + https://oss-runtime.dev/community/login.php + 2026-04-05 + monthly + 0.3 + + + + https://oss-runtime.dev/community/register.php + 2026-04-05 + monthly + 0.3 + + + diff --git a/website/update.php b/website/update.php new file mode 100644 index 0000000..b8efdf9 --- /dev/null +++ b/website/update.php @@ -0,0 +1,126 @@ + $path, 'sha' => $item['sha'], 'size' => $item['size'] ?? 0]; + } + } + + $log .= "获取到 " . count($websiteFiles) . " 个远程文件\n"; + + $updated = 0; $created = 0; $errors = 0; + $shaCacheFile = CACHE_DIR . '/file_shas.json'; + $localShas = file_exists($shaCacheFile) ? json_decode(@file_get_contents($shaCacheFile), true) : []; + if (!is_array($localShas)) $localShas = []; + + foreach ($websiteFiles as $file) { + $relativePath = substr($file['path'], strlen('website/')); + $localPath = __DIR__ . '/' . $relativePath; + $remoteSha = $file['sha']; + + $dir = dirname($localPath); + if (!is_dir($dir)) @mkdir($dir, 0755, true); + + $needsUpdate = false; + $reason = ''; + + if (!file_exists($localPath)) { + $needsUpdate = true; $reason = '文件缺失'; + } else { + $localSha = $localShas[$relativePath] ?? ''; + if ($localSha !== $remoteSha) { $needsUpdate = true; $reason = '内容已变更'; } + } + + if ($needsUpdate) { + $contentUrl = sprintf('%s/%s/%s/contents/%s?ref=%s', GITEE_API_BASE, GITEE_OWNER, GITEE_REPO, $file['path'], GITEE_BRANCH); + $contentData = httpGetJson($contentUrl); + if ($contentData && isset($contentData['content'])) { + $content = base64_decode(str_replace(["\n", "\r"], '', $contentData['content'])); + if (@file_put_contents($localPath, $content) !== false) { + $localShas[$relativePath] = $remoteSha; + if ($reason === '文件缺失') { $created++; $log .= "✅ 创建: $relativePath\n"; } + else { $updated++; $log .= "🔄 更新: $relativePath ($reason)\n"; } + } else { $errors++; $log .= "❌ 写入失败: $relativePath\n"; } + } else { $errors++; $log .= "❌ 获取内容失败: $relativePath\n"; } + } + } + + @file_put_contents($shaCacheFile, json_encode($localShas)); + @file_put_contents(CACHE_DIR . '/update_check.json', json_encode([ + 'timestamp' => time(), 'files' => count($websiteFiles), + 'updated' => $updated, 'created' => $created, 'errors' => $errors + ], JSON_PRETTY_PRINT)); + + $log .= "完成: 更新 $updated 个,创建 $created 个,错误 $errors 个\n"; + $log .= "下次检查: " . date('Y-m-d H:i:s', time() + UPDATE_INTERVAL) . "\n\n"; + @file_put_contents($logFile, $log, FILE_APPEND); + + } catch (Exception $e) { + $log .= "异常: " . $e->getMessage() . "\n"; + @file_put_contents($logFile, $log, FILE_APPEND); + } +} + +function httpGetJson($url) { + $context = stream_context_create(['http' => ['method' => 'GET', 'header' => ['User-Agent: OSS-AutoUpdate/1.0', 'Accept: application/json'], 'timeout' => 30], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + $response = @file_get_contents($url, false, $context); + if ($response === false) return null; + return json_decode($response, true); +} + +register_shutdown_function('autoUpdateWebsite');